Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
WEB_PUSH_OUTBOX_POLL_MS=1000
|
||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||
# Browser origins default to GATEWAY_PUBLIC_URL (Game API also accepts GAME_PUBLIC_URL).
|
||||
# Add exact HTTP(S) origins for a separate frontend; wildcards/null/path entries are rejected.
|
||||
# Requests with a different Origin fail with 403 before mutation. No-Origin clients still need auth.
|
||||
# API_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
|
||||
# GAME_PUBLIC_URL=http://localhost:14000
|
||||
# Gateway authentication protection shares atomic 60s counters under this prefix.
|
||||
# Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally;
|
||||
# expensive auth work is capped at 4/process. Forwarded headers are not trusted.
|
||||
# These are security defaults in auth/attemptBudget.ts, not disable-able env toggles.
|
||||
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||
GATEWAY_DB_SCHEMA=public
|
||||
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
||||
@@ -103,3 +112,7 @@ VITE_CASUAL_CHAT_URL=
|
||||
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
|
||||
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
|
||||
TURN_BUILD_COMMIT_SHA=
|
||||
|
||||
# Set both only when builds use the dedicated remote builder; never expose token to VITE_*.
|
||||
RELEASE_BUILDER_URL=
|
||||
RELEASE_BUILDER_TOKEN_FILE=
|
||||
|
||||
@@ -126,6 +126,10 @@ UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.m
|
||||
분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와
|
||||
검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로
|
||||
`build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요.
|
||||
- 짧은 제목·상태·버튼·정보 셀은 가로와 세로 정렬을 각각 결정합니다. 가로 가운데만
|
||||
지정하고 끝내지 말고, 왼쪽/오른쪽 정렬 값도 세로 가운데가 적절한지 확인합니다.
|
||||
여러 줄 본문·로그는 읽기 시작점을 보존하고, 좁은 화면의 줄바꿈과 overflow를
|
||||
검증합니다. 구현 방법과 예외는 [두 축 텍스트 정렬](docs/frontend-css-architecture.md#text-alignment-on-both-axes-2026-09-29)을 따릅니다.
|
||||
- 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`,
|
||||
`.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요.
|
||||
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
||||
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
||||
|
||||
## 독자별 시작점
|
||||
|
||||
- 게임이 처음인 플레이어: [삼국지 모의전투 첫 안내](docs/user/index.md)
|
||||
- 프로그래밍 기초만 아는 독자: [화면·서버·저장부터 배우기](docs/developer/first-steps.md)
|
||||
- 개발 경험이 있는 독자: [요청·실행·운영의 구조 읽기](docs/developer/system-walkthrough.md)
|
||||
- 전체 문서 탐색: [핸드북](docs/index.md), [게시판 용어 사전](docs/user/glossary.md)
|
||||
|
||||
## 저장소 구성
|
||||
|
||||
| 경로 | 책임 |
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
"@trpc/server": "^11.8.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify": "^5.12.5",
|
||||
"redis": "^5.10.0",
|
||||
"sanitize-html": "2.17.6",
|
||||
"sharp": "^0.35.0",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
parseSessionRevocationWatermark,
|
||||
SessionRevocationCommandBudget,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
|
||||
}
|
||||
|
||||
export interface FlushStore {
|
||||
getFlushedAt(userId: string): Date | null;
|
||||
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
|
||||
applyFlush(event: GatewayUserFlushEvent): void;
|
||||
}
|
||||
|
||||
@@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore {
|
||||
}
|
||||
}
|
||||
|
||||
interface RevocationRedisClient {
|
||||
readonly isReady?: boolean;
|
||||
mGet(keys: string[]): Promise<(string | null)[]>;
|
||||
withAbortSignal?(signal: AbortSignal): RevocationRedisClient;
|
||||
}
|
||||
|
||||
/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */
|
||||
export class RedisFlushStore implements FlushStore {
|
||||
private readonly local = new Map<string, Date>();
|
||||
private readonly commands = new SessionRevocationCommandBudget(64);
|
||||
constructor(
|
||||
private readonly client: RevocationRedisClient,
|
||||
private readonly channel: string
|
||||
) {}
|
||||
|
||||
private commandClient(): RevocationRedisClient {
|
||||
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
|
||||
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
}
|
||||
|
||||
async assertInitialized(): Promise<void> {
|
||||
const values = await this.commands.run(() =>
|
||||
this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)])
|
||||
);
|
||||
if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!))
|
||||
throw new Error('Gateway session revocation must be initialized before game API startup.');
|
||||
}
|
||||
|
||||
async getFlushedAt(userId: string): Promise<Date | null> {
|
||||
const values = await this.commands.run(() =>
|
||||
this.commandClient().mGet([
|
||||
buildSessionRevocationBaselineKey(this.channel),
|
||||
buildUserSessionRevocationKey(this.channel, userId),
|
||||
])
|
||||
);
|
||||
if (values.length !== 2) throw new Error('Session revocation storage is unavailable.');
|
||||
const baseline = parseSessionRevocationWatermark(values[0]!);
|
||||
if (!baseline) throw new Error('Session revocation storage is unavailable.');
|
||||
const persisted = parseSessionRevocationWatermark(values[1]!);
|
||||
return new Date(
|
||||
Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
applyFlush(event: GatewayUserFlushEvent): void {
|
||||
const parsed = new Date(event.flushedAt);
|
||||
if (Number.isNaN(parsed.getTime())) return;
|
||||
const prior = this.local.get(event.userId);
|
||||
if (!prior || parsed > prior) {
|
||||
this.local.delete(event.userId);
|
||||
this.local.set(event.userId, parsed);
|
||||
}
|
||||
// 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다.
|
||||
if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushSubscriber {
|
||||
private readonly client: {
|
||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
|
||||
const parseReconcileInterval = (value: string | undefined): number => {
|
||||
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
|
||||
|
||||
export interface GameApiConfig {
|
||||
host: string;
|
||||
corsAllowedOrigins: string[];
|
||||
port: number;
|
||||
trpcPath: string;
|
||||
eventsPath: string;
|
||||
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
|
||||
return {
|
||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
|
||||
env.GATEWAY_PUBLIC_URL,
|
||||
env.GAME_PUBLIC_URL,
|
||||
]),
|
||||
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||
|
||||
@@ -66,8 +66,10 @@ const resolveMapName = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
||||
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
|
||||
export const loadMapLayoutByName = async (
|
||||
mapName: string,
|
||||
options: MapLayoutLoaderOptions = {}
|
||||
): Promise<MapLayout> => {
|
||||
const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap;
|
||||
const cached = useCache ? layoutCache.get(mapName) : undefined;
|
||||
if (cached) {
|
||||
@@ -98,3 +100,8 @@ export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOp
|
||||
}
|
||||
return layout;
|
||||
};
|
||||
|
||||
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
||||
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
|
||||
return loadMapLayoutByName(mapName, options);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
withPlayAuditSavepoint,
|
||||
persistAuditDiplomacyEvents,
|
||||
type AuditDiplomacyEventDraft,
|
||||
type GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
|
||||
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
@@ -104,6 +109,7 @@ const persistEffects = async (
|
||||
}
|
||||
): Promise<void> => {
|
||||
const changes: AuditDiplomacyEventDraft[] = [];
|
||||
let collectionFailed = false;
|
||||
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
|
||||
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
|
||||
state: row.stateCode,
|
||||
@@ -129,22 +135,26 @@ const persistEffects = async (
|
||||
},
|
||||
});
|
||||
if (audit) {
|
||||
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||
const previous = states.get(key);
|
||||
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||
const before = project(previous);
|
||||
const after = project(updated);
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
changes.push({
|
||||
...audit.base,
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
ordinal: changes.length + 1,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
try {
|
||||
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||
const previous = states.get(key);
|
||||
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||
const before = project(previous);
|
||||
const after = project(updated);
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
changes.push({
|
||||
...audit.base,
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
ordinal: changes.length + 1,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
states.set(key, updated);
|
||||
} catch {
|
||||
collectionFailed = true;
|
||||
}
|
||||
states.set(key, updated);
|
||||
}
|
||||
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
||||
const patch = effect.patch;
|
||||
@@ -159,7 +169,11 @@ const persistEffects = async (
|
||||
}
|
||||
}
|
||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
||||
await persistAuditDiplomacyEvents(db, changes);
|
||||
if (changes.length || collectionFailed)
|
||||
await withPlayAuditSavepoint(db, async (auditDb) => {
|
||||
if (collectionFailed) throw new Error('Audit relation collection failed');
|
||||
await persistAuditDiplomacyEvents(auditDb, changes);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
const MAX_PENDING_FRAMES = 32;
|
||||
const MAX_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
/** Revalidate before work and before writing its result; never retain unbounded frames. */
|
||||
export class AuthenticatedRealtimeConnection {
|
||||
private queue = Promise.resolve();
|
||||
private pending = 0;
|
||||
private ended = false;
|
||||
private readonly expiryTimer: ReturnType<typeof setTimeout>;
|
||||
private cleanup = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
readonly auth: GameSessionTokenPayload,
|
||||
private readonly response: ServerResponse,
|
||||
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
|
||||
) {
|
||||
// AccessTokenStore requires a full remaining TTL second on every lookup.
|
||||
this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999));
|
||||
this.expiryTimer.unref();
|
||||
response.once('close', this.close);
|
||||
response.once('error', this.close);
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.ended;
|
||||
}
|
||||
|
||||
whenIdle(): Promise<void> {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
onClose(cleanup: () => void): void {
|
||||
if (this.ended) cleanup();
|
||||
else this.cleanup.add(cleanup);
|
||||
}
|
||||
|
||||
private async currentAuth(): Promise<GameSessionTokenPayload | null> {
|
||||
if (this.ended) return null;
|
||||
const current = await this.resolveAuth();
|
||||
if (
|
||||
!current ||
|
||||
this.ended ||
|
||||
current.sessionId !== this.auth.sessionId ||
|
||||
current.user.id !== this.auth.user.id ||
|
||||
current.profile !== this.auth.profile
|
||||
) {
|
||||
this.close();
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
enqueue(work: (auth: GameSessionTokenPayload) => Promise<string | null>): void {
|
||||
if (this.ended) return;
|
||||
if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.pending += 1;
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
const current = await this.currentAuth();
|
||||
if (!current) return;
|
||||
const frame = await work(current);
|
||||
if (!frame || !(await this.currentAuth())) return;
|
||||
if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close();
|
||||
})
|
||||
.catch(() => {
|
||||
// Redis/auth failure is fail closed. Reconnect must authenticate again.
|
||||
this.close();
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
close = (): void => {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
clearTimeout(this.expiryTimer);
|
||||
this.response.off('close', this.close);
|
||||
this.response.off('error', this.close);
|
||||
for (const cleanup of this.cleanup) cleanup();
|
||||
this.cleanup.clear();
|
||||
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const DEFAULT_LIMITS = { connections: 512, perUser: 8, opening: 32 } as const;
|
||||
const OPENING_DEADLINE_MS = 5000;
|
||||
|
||||
export interface RealtimeConnectionReservation {
|
||||
canContinue(): boolean;
|
||||
reserveUser(userId: string): boolean;
|
||||
startStreaming(): boolean;
|
||||
cancelOpening(): void;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** One game API process owns both pending setup and live streams; no waiting queue. */
|
||||
export class RealtimeConnectionAdmission {
|
||||
private readonly limits: { connections: number; perUser: number; opening: number };
|
||||
private total = 0;
|
||||
private opening = 0;
|
||||
private closed = false;
|
||||
private readonly byUser = new Map<string, number>();
|
||||
private readonly pending = new Set<() => void>();
|
||||
|
||||
constructor(limits: Partial<{ connections: number; perUser: number; opening: number }> = {}) {
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
if (Object.values(this.limits).some((value) => !Number.isSafeInteger(value) || value < 1))
|
||||
throw new Error('Invalid realtime connection limits.');
|
||||
}
|
||||
|
||||
begin(onTimeout: () => void): RealtimeConnectionReservation | null {
|
||||
if (this.closed || this.total >= this.limits.connections || this.opening >= this.limits.opening) return null;
|
||||
this.total += 1;
|
||||
this.opening += 1;
|
||||
let userId: string | null = null;
|
||||
let streaming = false;
|
||||
let cancelled = false;
|
||||
let released = false;
|
||||
const deadline = Date.now() + OPENING_DEADLINE_MS;
|
||||
const expire = () => {
|
||||
if (released || streaming || cancelled) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
onTimeout();
|
||||
};
|
||||
const timer = setTimeout(expire, OPENING_DEADLINE_MS);
|
||||
timer.unref();
|
||||
this.pending.add(expire);
|
||||
const canContinue = () => {
|
||||
if (!released && !streaming && !cancelled && Date.now() >= deadline) expire();
|
||||
return !released && !streaming && !cancelled;
|
||||
};
|
||||
return {
|
||||
canContinue,
|
||||
reserveUser: (id) => {
|
||||
if (!canContinue() || userId !== null) return false;
|
||||
const count = this.byUser.get(id) ?? 0;
|
||||
if (count >= this.limits.perUser) return false;
|
||||
this.byUser.set(id, count + 1);
|
||||
userId = id;
|
||||
return true;
|
||||
},
|
||||
startStreaming: () => {
|
||||
if (!canContinue() || userId === null) return false;
|
||||
streaming = true;
|
||||
this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
return true;
|
||||
},
|
||||
cancelOpening: () => {
|
||||
if (released || streaming) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
// Disconnect/timeout does not prove the awaited Redis/SQL operation has settled.
|
||||
},
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.total -= 1;
|
||||
if (!streaming) this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
if (userId !== null) {
|
||||
const count = (this.byUser.get(userId) ?? 1) - 1;
|
||||
if (count === 0) this.byUser.delete(userId);
|
||||
else this.byUser.set(userId, count);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
for (const expire of this.pending) expire();
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,15 @@ export const authRouter = router({
|
||||
message: 'Game access is restricted for this account.',
|
||||
});
|
||||
}
|
||||
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id);
|
||||
let flushedAt: Date | null;
|
||||
try {
|
||||
flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Authentication protection is unavailable.',
|
||||
});
|
||||
}
|
||||
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
ImageWorkBudget,
|
||||
ImageWorkBusyError,
|
||||
ImageUploadError,
|
||||
isRasterImage,
|
||||
} from '@sammo-ts/common/images/imageUpload';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'crypto';
|
||||
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
const boardImageBudget = new ImageWorkBudget();
|
||||
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
|
||||
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
||||
const MAX_LONG_EDGE = 2048;
|
||||
const WEBP_QUALITY = 80;
|
||||
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
|
||||
buffer: Buffer,
|
||||
{ animated, resize }: { animated: boolean; resize: boolean }
|
||||
): Promise<Buffer> => {
|
||||
let pipeline = sharp(buffer, { animated: true });
|
||||
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||
if (resize) {
|
||||
pipeline = pipeline.resize({
|
||||
width: MAX_LONG_EDGE,
|
||||
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
|
||||
...(animated ? { animated: true } : {}),
|
||||
};
|
||||
|
||||
return pipeline.webp(webpOptions).toBuffer();
|
||||
return pipeline
|
||||
.webp(webpOptions)
|
||||
.toBuffer()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
|
||||
let pipeline = sharp(buffer, { animated: true });
|
||||
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||
if (resize) {
|
||||
pipeline = pipeline.resize({
|
||||
width: MAX_LONG_EDGE,
|
||||
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer();
|
||||
return pipeline
|
||||
.avif({ quality: 60, effort: 4 })
|
||||
.toBuffer()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
export const boardRouter = router({
|
||||
@@ -213,70 +232,116 @@ export const boardRouter = router({
|
||||
|
||||
return { id: comment.id };
|
||||
}),
|
||||
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, true);
|
||||
uploadImage: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dataUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, true);
|
||||
|
||||
const buffer = parseDataUrl(input.dataUrl);
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
|
||||
}
|
||||
try {
|
||||
return await boardImageBudget.run(async () => {
|
||||
const buffer = parseDataUrl(input.dataUrl);
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: 'PAYLOAD_TOO_LARGE',
|
||||
message: '이미지 용량 제한(1MB)을 초과했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
}
|
||||
if (!isRasterImage(buffer))
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
});
|
||||
if ((metadata.pages ?? 1) > 256)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
|
||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const format = metadata.format;
|
||||
const isAvif = metadata.mediaType === 'image/avif';
|
||||
const isAnimated = (metadata.pages ?? 1) > 1;
|
||||
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
|
||||
const format = metadata.format;
|
||||
const isAvif = metadata.mediaType === 'image/avif';
|
||||
const isAnimated = (metadata.pages ?? 1) > 1;
|
||||
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
|
||||
|
||||
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
|
||||
if (!allowed.has(format)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
}
|
||||
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
|
||||
if (!allowed.has(format)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
}
|
||||
|
||||
let outputBuffer = buffer;
|
||||
let outputFormat = isAvif ? 'avif' : 'webp';
|
||||
let outputBuffer = buffer;
|
||||
let outputFormat = isAvif ? 'avif' : 'webp';
|
||||
|
||||
if (isAvif) {
|
||||
if (needsResize) {
|
||||
outputBuffer = await buildAvifBuffer(buffer, true);
|
||||
if (isAvif) {
|
||||
if (needsResize) {
|
||||
outputBuffer = await buildAvifBuffer(buffer, true);
|
||||
}
|
||||
} else {
|
||||
const webpBuffer = await buildWebpBuffer(buffer, {
|
||||
animated: isAnimated || format === 'gif',
|
||||
resize: needsResize,
|
||||
});
|
||||
if (
|
||||
format === 'webp' &&
|
||||
!needsResize &&
|
||||
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
|
||||
) {
|
||||
outputBuffer = buffer;
|
||||
outputFormat = 'webp';
|
||||
} else {
|
||||
outputBuffer = webpBuffer;
|
||||
outputFormat = 'webp';
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.contentImageUpload) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
||||
const uploaded = await ctx.contentImageUpload.upload({
|
||||
filename,
|
||||
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
|
||||
body: outputBuffer,
|
||||
});
|
||||
|
||||
const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata();
|
||||
|
||||
return {
|
||||
url: uploaded.publicUrl,
|
||||
width: outputMeta.width ?? metadata.width,
|
||||
height: outputMeta.height ?? metadata.height,
|
||||
format: outputFormat,
|
||||
animated: isAnimated,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageWorkBusyError)
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
if (error instanceof ImageUploadError)
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const webpBuffer = await buildWebpBuffer(buffer, {
|
||||
animated: isAnimated || format === 'gif',
|
||||
resize: needsResize,
|
||||
});
|
||||
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
|
||||
outputBuffer = buffer;
|
||||
outputFormat = 'webp';
|
||||
} else {
|
||||
outputBuffer = webpBuffer;
|
||||
outputFormat = 'webp';
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.contentImageUpload) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||
}
|
||||
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
||||
const uploaded = await ctx.contentImageUpload.upload({
|
||||
filename,
|
||||
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
|
||||
body: outputBuffer,
|
||||
});
|
||||
|
||||
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
|
||||
|
||||
return {
|
||||
url: uploaded.publicUrl,
|
||||
width: outputMeta.width ?? metadata.width,
|
||||
height: outputMeta.height ?? metadata.height,
|
||||
format: outputFormat,
|
||||
animated: isAnimated,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
import { asRecord, readOfficeRequest, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import {
|
||||
@@ -989,7 +989,7 @@ export const generalRouter = router({
|
||||
|
||||
const now = new Date();
|
||||
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
|
||||
const [onlineAccess, ownNation, openPolls, gameTime] = await Promise.all([
|
||||
const [onlineAccess, ownNation, openPolls, gameTime, officeRequestRows] = await Promise.all([
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
where: {
|
||||
lastActionAt: {
|
||||
@@ -1020,6 +1020,15 @@ export const generalRouter = router({
|
||||
},
|
||||
}),
|
||||
loadCurrentGameTime(ctx.db, now),
|
||||
me.nationId > 0 && me.officerLevel >= 5 && !asRecord(me.penalty).noChief
|
||||
? ctx.db.general.findMany({
|
||||
where: {
|
||||
nationId: me.nationId,
|
||||
meta: { path: ['cityOfficeRequest', 'status'], equals: 'pending' },
|
||||
},
|
||||
select: { id: true, userId: true, meta: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||
// logical clock advances through negative ticks before the opening anchor,
|
||||
@@ -1088,6 +1097,19 @@ export const generalRouter = router({
|
||||
|
||||
return {
|
||||
serverId,
|
||||
cityOfficeRequests: {
|
||||
generalId: me.id,
|
||||
nationId: me.nationId,
|
||||
ids: officeRequestRows.flatMap((row) => {
|
||||
const request = readOfficeRequest(row.meta);
|
||||
return request?.status === 'pending' &&
|
||||
request.generalId === row.id &&
|
||||
request.nationId === me.nationId &&
|
||||
request.userId === row.userId
|
||||
? [request.id]
|
||||
: [];
|
||||
}),
|
||||
},
|
||||
onlineUserCount: onlineGenerals.length,
|
||||
onlineNations,
|
||||
onlineGenerals: myOnlineGenerals,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
asRecord,
|
||||
GAME_TICKS_PER_TURN,
|
||||
officeRequestQuarter,
|
||||
readOfficeRequest,
|
||||
type CityOfficeLevel,
|
||||
} from '@sammo-ts/common';
|
||||
import { resolveOfficeRequestEligibility, type OfficeCandidate } from '@sammo-ts/logic';
|
||||
import { accessAuthedProcedure, engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
|
||||
import { assertNationAccess, resolveChiefStatMin } from '../shared.js';
|
||||
import { loadCurrentGameTime } from '../../../services/gameClock.js';
|
||||
|
||||
export const cityOfficeRequest = engineAuthedProcedure
|
||||
.input(
|
||||
z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('request'),
|
||||
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(['approve', 'reject', 'withdraw']),
|
||||
targetGeneralId: z.number().int().positive(),
|
||||
officeRequestId: z.string().min(1),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
...input,
|
||||
type: 'cityOfficeRequest',
|
||||
userId: getAuthenticatedUserId(ctx),
|
||||
generalId: general.id,
|
||||
...(ctx.requestId
|
||||
? { requestId: `${ctx.requestId}:nation.cityOfficeRequest:engine:0:cityOfficeRequest` }
|
||||
: {}),
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'cityOfficeRequest')
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
if (!result.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
export const getCityOfficeRequests = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const [nation, cities, rows, state, clock] = await Promise.all([
|
||||
ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { meta: true } }),
|
||||
ctx.db.city.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
select: { id: true, nationId: true, name: true, meta: true },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
officerLevel: true,
|
||||
npcState: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
meta: true,
|
||||
penalty: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.worldState.findFirst(),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
if (!nation || !state)
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가 또는 게임 상태를 찾을 수 없습니다.' });
|
||||
const candidates = rows.map((row) => ({ ...row, intelligence: row.intel }));
|
||||
const applicant: OfficeCandidate = { ...me, intelligence: me.intel };
|
||||
const city = cities.find((entry) => entry.id === me.cityId) ?? null;
|
||||
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
|
||||
const own = readOfficeRequest(me.meta);
|
||||
const requests = candidates.flatMap((general) => {
|
||||
const request = readOfficeRequest(general.meta);
|
||||
if (
|
||||
!request ||
|
||||
request.generalId !== general.id ||
|
||||
request.nationId !== me.nationId ||
|
||||
request.userId !== general.userId
|
||||
)
|
||||
return [];
|
||||
return [{ general, request }];
|
||||
});
|
||||
const evaluate = (general: OfficeCandidate, cityId: number, level: CityOfficeLevel) =>
|
||||
resolveOfficeRequestEligibility({
|
||||
applicant: general,
|
||||
city: cities.find((entry) => entry.id === cityId) ?? null,
|
||||
level,
|
||||
incumbent:
|
||||
candidates.find(
|
||||
(entry) =>
|
||||
entry.officerLevel === level &&
|
||||
Number(asRecord(entry.meta).officerCity ?? asRecord(entry.meta).officer_city ?? 0) === cityId
|
||||
) ?? null,
|
||||
nationMeta: nation.meta,
|
||||
chiefStatMin: resolveChiefStatMin(state),
|
||||
});
|
||||
const canManage = me.officerLevel >= 5 && !asRecord(me.penalty).noChief;
|
||||
const nowTick = clock.tick;
|
||||
const options = ([4, 3, 2] as const).map((level) => {
|
||||
const check = evaluate(applicant, me.cityId, level);
|
||||
const occupied = requests.some(
|
||||
({ request }) =>
|
||||
request.status === 'pending' && request.cityId === me.cityId && request.officerLevel === level
|
||||
);
|
||||
const reason =
|
||||
check.reason ??
|
||||
(own?.status === 'pending'
|
||||
? '요청 대기 중'
|
||||
: own?.quarter === quarter
|
||||
? '이번 분기 자원 완료'
|
||||
: occupied
|
||||
? '다른 장수 자원 중'
|
||||
: nowTick === null
|
||||
? '게임 시계 준비 중'
|
||||
: null);
|
||||
return { level, ...check, allowed: !reason, reason };
|
||||
});
|
||||
return {
|
||||
canManage,
|
||||
generalId: me.id,
|
||||
nationId: me.nationId,
|
||||
serverId: String(asRecord(state.meta).serverId ?? ctx.profile?.name ?? 'game'),
|
||||
city: city ? { id: city.id, name: city.name } : null,
|
||||
options,
|
||||
running: clock.running,
|
||||
tickSeconds: state.tickSeconds,
|
||||
requests: requests
|
||||
.filter(({ general, request }) => general.id === me.id || (canManage && request.status === 'pending'))
|
||||
.map(({ general, request }) => {
|
||||
const check = evaluate(general, request.cityId, request.officerLevel);
|
||||
const defaultDecision =
|
||||
request.defaultDecision === 'reject' || check.defaultDecision === 'reject'
|
||||
? ('reject' as const)
|
||||
: ('approve' as const);
|
||||
const months = Math.max(
|
||||
0,
|
||||
Math.ceil((request.dueTick - Number(state.lastTurnTick ?? 0)) / GAME_TICKS_PER_TURN)
|
||||
);
|
||||
const monthIndex = state.currentYear * 12 + state.currentMonth - 1 + months;
|
||||
return {
|
||||
id: request.id,
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
cityId: request.cityId,
|
||||
cityName: cities.find((entry) => entry.id === request.cityId)?.name ?? '-',
|
||||
officerLevel: request.officerLevel,
|
||||
status: request.status,
|
||||
defaultDecision,
|
||||
defaultReason: check.defaultReason === 'secret' ? ('secret' as const) : request.defaultReason,
|
||||
resultReason: request.resultReason ?? null,
|
||||
dueYear: Math.floor(monthIndex / 12),
|
||||
dueMonth: (monthIndex % 12) + 1,
|
||||
remainingSeconds:
|
||||
nowTick === null
|
||||
? null
|
||||
: Math.max(0, ((request.dueTick - nowTick) / GAME_TICKS_PER_TURN) * state.tickSeconds),
|
||||
invalidReason: request.status === 'pending' ? check.reason : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cityOfficeRequest, getCityOfficeRequests } from './endpoints/cityOfficeRequests.js';
|
||||
import { router } from '../../trpc.js';
|
||||
import { appoint } from './endpoints/appoint.js';
|
||||
import { changePermission } from './endpoints/changePermission.js';
|
||||
@@ -25,6 +26,8 @@ export const nationRouter = router({
|
||||
getSecretGeneralList,
|
||||
getCityOverview,
|
||||
getPersonnelInfo,
|
||||
getCityOfficeRequests,
|
||||
cityOfficeRequest,
|
||||
getStratFinan,
|
||||
setNotice,
|
||||
setScoutMsg,
|
||||
|
||||
@@ -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 }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const month = input.month ?? { year: world.year, month: world.month };
|
||||
// 장수 턴은 월 경계와 동시에 실행되지 않는다. 현재 상태에서는 마지막으로
|
||||
// 실제 결정이 저장된 월을 열고, 명시한 과거 월은 빈 월이어도 그대로 보존한다.
|
||||
const latest =
|
||||
!input.month && world.serverId
|
||||
? await tx.playAuditDecision.findFirst({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
generalId: input.generalId,
|
||||
phase: input.phase,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ year: { gt: world.startYear } },
|
||||
{ year: world.startYear, month: { gte: world.startMonth } },
|
||||
],
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{ year: { lt: world.year } },
|
||||
{ year: world.year, month: { lte: world.month } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ year: 'desc' }, { month: 'desc' }, { tick: 'desc' }, { id: 'desc' }],
|
||||
select: { year: true, month: true },
|
||||
})
|
||||
: null;
|
||||
const month = input.month ?? latest ?? { year: world.year, month: world.month };
|
||||
const ordinal = monthOrdinal(month.year, month.month);
|
||||
if (
|
||||
ordinal < monthOrdinal(world.startYear, world.startMonth) ||
|
||||
@@ -206,6 +234,8 @@ export const decisionHistory = auditProcedure
|
||||
return {
|
||||
...world,
|
||||
month,
|
||||
currentMonth: { year: world.year, month: world.month },
|
||||
selection: input.month ? ('MONTH' as const) : ('LATEST' as const),
|
||||
coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
items: rows.slice(0, input.limit).map(project),
|
||||
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
import { generalSelect, projectCurrentGeneral, zAuditGeneralData } from './projection.js';
|
||||
|
||||
export const zGeneralSort = z.enum([
|
||||
'id',
|
||||
'gold',
|
||||
'rice',
|
||||
'crew',
|
||||
'train',
|
||||
'atmos',
|
||||
'leadership',
|
||||
'strength',
|
||||
'intelligence',
|
||||
'experience',
|
||||
'dedication',
|
||||
'dex1',
|
||||
'dex2',
|
||||
'dex3',
|
||||
'dex4',
|
||||
'dex5',
|
||||
]);
|
||||
export const zGeneralCursor = z.object({ id: z.number().int().nonnegative(), value: z.number().nullable() }).strict();
|
||||
const columns: Record<z.infer<typeof zGeneralSort>, string> = {
|
||||
id: 'id',
|
||||
gold: 'gold',
|
||||
rice: 'rice',
|
||||
crew: 'crew',
|
||||
train: 'train',
|
||||
atmos: 'atmos',
|
||||
leadership: 'leadership',
|
||||
strength: 'strength',
|
||||
intelligence: 'intel',
|
||||
experience: 'experience',
|
||||
dedication: 'dedication',
|
||||
dex1: 'dex1',
|
||||
dex2: 'dex2',
|
||||
dex3: 'dex3',
|
||||
dex4: 'dex4',
|
||||
dex5: 'dex5',
|
||||
};
|
||||
export const readSortedGenerals = async (
|
||||
tx: GamePrisma.TransactionClient,
|
||||
input: {
|
||||
sort: z.infer<typeof zGeneralSort>;
|
||||
order: 'asc' | 'desc';
|
||||
limit: number;
|
||||
cursor?: number | z.infer<typeof zGeneralCursor>;
|
||||
nationId?: number;
|
||||
cityId?: number;
|
||||
population?: 'human' | 'npc' | 'troopNpc';
|
||||
name?: string;
|
||||
},
|
||||
sampleId?: string
|
||||
) => {
|
||||
if (typeof input.cursor === 'number')
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '정렬 조건에 맞는 다음 페이지를 선택해 주세요.' });
|
||||
// SQL 식별자/JSON 경로는 서버 allowlist에서만 조립한다. 사용자 문자열은 parameter다.
|
||||
const id = GamePrisma.raw(sampleId ? 'general_id' : 'id');
|
||||
const path = ['leadership', 'strength', 'intelligence'].includes(input.sort)
|
||||
? `stats,${input.sort}`
|
||||
: input.sort.startsWith('dex')
|
||||
? `dex,${input.sort}`
|
||||
: input.sort;
|
||||
const metric = sampleId
|
||||
? GamePrisma.raw(`(data #>> '{${path}}')::double precision`)
|
||||
: input.sort.startsWith('dex')
|
||||
? GamePrisma.raw(
|
||||
`CASE WHEN jsonb_typeof(meta->'${input.sort}') = 'number' THEN (meta->>'${input.sort}')::double precision ELSE 0 END`
|
||||
)
|
||||
: GamePrisma.raw(columns[input.sort]);
|
||||
const filters: GamePrisma.Sql[] = [];
|
||||
if (sampleId) filters.push(GamePrisma.sql`sample_id = ${sampleId}`);
|
||||
if (input.nationId !== undefined) filters.push(GamePrisma.sql`nation_id = ${input.nationId}`);
|
||||
if (input.cityId !== undefined) filters.push(GamePrisma.sql`city_id = ${input.cityId}`);
|
||||
if (input.population === 'human') filters.push(GamePrisma.sql`npc_state < 2`);
|
||||
if (input.population === 'npc') filters.push(GamePrisma.sql`npc_state >= 2 AND npc_state <> 5`);
|
||||
if (input.population === 'troopNpc') filters.push(GamePrisma.sql`npc_state = 5`);
|
||||
if (input.name)
|
||||
filters.push(
|
||||
GamePrisma.sql`${GamePrisma.raw(sampleId ? "data->>'name'" : 'name')} LIKE ${`%${input.name.replace(/[\\%_]/g, '\\$&')}%`}`
|
||||
);
|
||||
if (input.cursor) {
|
||||
const { value, id: cursorId } = input.cursor;
|
||||
filters.push(
|
||||
value === null
|
||||
? GamePrisma.sql`(${metric} IS NULL AND ${id} > ${cursorId})`
|
||||
: GamePrisma.sql`(${metric} ${GamePrisma.raw(input.order === 'asc' ? '>' : '<')} ${value} OR (${metric} = ${value} AND ${id} > ${cursorId}) OR ${metric} IS NULL)`
|
||||
);
|
||||
}
|
||||
const rows = await tx.$queryRaw<{ id: number; value: number | null; data: unknown }[]>(GamePrisma.sql`
|
||||
SELECT ${id} AS id, ${metric} AS value, ${sampleId ? GamePrisma.raw('data') : GamePrisma.sql`NULL`} AS data
|
||||
FROM ${GamePrisma.raw(sampleId ? 'play_audit_general' : 'general')}
|
||||
WHERE ${filters.length ? GamePrisma.join(filters, ' AND ') : GamePrisma.sql`TRUE`}
|
||||
ORDER BY ${metric} ${GamePrisma.raw(input.order.toUpperCase())} NULLS LAST, ${id} ASC LIMIT ${input.limit + 1}
|
||||
`);
|
||||
const page = rows.slice(0, input.limit);
|
||||
const current = sampleId
|
||||
? []
|
||||
: await tx.general.findMany({ where: { id: { in: page.map((row) => row.id) } }, select: generalSelect });
|
||||
const byId = new Map(current.map((row) => [row.id, projectCurrentGeneral(row)]));
|
||||
return {
|
||||
items: page.map((row) => (sampleId ? zAuditGeneralData.parse(row.data) : byId.get(row.id)!)),
|
||||
nextCursor:
|
||||
rows.length > input.limit ? { id: page[page.length - 1]!.id, value: page[page.length - 1]!.value } : null,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
import { cityMap } from './cityMap.js';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { readSortedGenerals, zGeneralSort, zGeneralCursor } from './generalSort.js';
|
||||
import { requestState } from './requests.js';
|
||||
import { decisionHistory, decisionDetail } from './decisions.js';
|
||||
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||
@@ -27,6 +30,7 @@ import {
|
||||
} from './projection.js';
|
||||
|
||||
export const playAuditRouter = router({
|
||||
cityMap,
|
||||
requestState,
|
||||
decisionHistory,
|
||||
decisionDetail,
|
||||
@@ -161,11 +165,20 @@ export const playAuditRouter = router({
|
||||
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
|
||||
name: z.string().trim().max(64).optional(),
|
||||
order: z.enum(['asc', 'desc']).default('asc'),
|
||||
sort: zGeneralSort.default('id'),
|
||||
cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(),
|
||||
})
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
if (input.sort !== 'id') {
|
||||
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
|
||||
if (input.at && !sample) return { ...world, sample, collected: false, items: [], nextCursor: null };
|
||||
return { ...world, sample, collected: true, ...(await readSortedGenerals(tx, input, sample?.id)) };
|
||||
}
|
||||
if (typeof input.cursor === 'object')
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 번호 정렬의 다음 페이지가 아닙니다.' });
|
||||
const npcState =
|
||||
input.population === 'human'
|
||||
? { lt: 2 }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||
@@ -5,6 +7,7 @@ import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth }
|
||||
const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
|
||||
const zPopulation = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
crew: z.number().nonnegative().nullable().default(null),
|
||||
gold: z.number(),
|
||||
rice: z.number(),
|
||||
dex: zDex,
|
||||
@@ -92,6 +95,34 @@ export const summarizeNationPeriod = (start: number, width: number, months: Nati
|
||||
};
|
||||
};
|
||||
|
||||
/** 같은 transaction에서 commit된 당월 정산만 반환한다. 미관측을 0으로 만들지 않는다. */
|
||||
export const projectCurrentSettlement = (meta: unknown, year: number, month: number, nationId: number) => {
|
||||
const flows = asRecord(asRecord(meta).playAuditFlows);
|
||||
const matches = flows.year === year && flows.month === month;
|
||||
const entries = asRecord(flows.entries);
|
||||
const resource = (key: 'gold' | 'rice') => {
|
||||
const row = asRecord(entries[`${nationId}:${key}`]);
|
||||
if (
|
||||
!matches ||
|
||||
row.nationId !== nationId ||
|
||||
row.resource !== key ||
|
||||
typeof row.income !== 'number' ||
|
||||
!Number.isFinite(row.income) ||
|
||||
typeof row.paid !== 'number' ||
|
||||
!Number.isFinite(row.paid)
|
||||
)
|
||||
return null;
|
||||
return { income: row.income, paid: row.paid };
|
||||
};
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
gold: resource('gold'),
|
||||
rice: resource('rice'),
|
||||
complete: matches && flows.complete === true,
|
||||
};
|
||||
};
|
||||
|
||||
const zCalendarMonth = zAuditMonth.omit({ kind: true });
|
||||
export const nationSeries = auditProcedure
|
||||
.input(
|
||||
@@ -151,6 +182,35 @@ export const nationSeries = auditProcedure
|
||||
})
|
||||
: [];
|
||||
const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)]));
|
||||
// 기존 집계는 같은 월의 장수 표본을 DB에서 합산한다. 원문은 전송하지 않는다.
|
||||
const legacySamples = [...dataBySample]
|
||||
.filter(([, nation]) => Object.values(nation.populations).some((group) => group.crew === null))
|
||||
.map(([id]) => id);
|
||||
if (legacySamples.length) {
|
||||
const troops = await tx.$queryRaw<
|
||||
{ sampleId: string; population: 'human' | 'npc' | 'troopNpc'; count: number; crew: number | null }[]
|
||||
>(GamePrisma.sql`
|
||||
SELECT sample_id AS "sampleId",
|
||||
CASE WHEN npc_state = 5 THEN 'troopNpc' WHEN npc_state < 2 THEN 'human' ELSE 'npc' END AS population,
|
||||
count(*)::int AS count,
|
||||
CASE WHEN bool_and(jsonb_typeof(data->'crew') = 'number' AND data->'crew' IS NOT NULL)
|
||||
THEN sum(CASE WHEN jsonb_typeof(data->'crew') = 'number' THEN (data->>'crew')::double precision END) ELSE NULL END AS crew
|
||||
FROM play_audit_general
|
||||
WHERE nation_id = ${input.nationId} AND sample_id IN (${GamePrisma.join(legacySamples)})
|
||||
GROUP BY sample_id, population
|
||||
`);
|
||||
for (const sampleId of legacySamples) {
|
||||
const nation = dataBySample.get(sampleId)!;
|
||||
for (const key of ['human', 'npc', 'troopNpc'] as const) {
|
||||
const group = nation.populations[key];
|
||||
if (group.crew !== null) continue;
|
||||
const row = troops.find((item) => item.sampleId === sampleId && item.population === key);
|
||||
// 저장 인원수와 원본 표본 수가 같을 때만 확정한다.
|
||||
if (group.count === 0 && !row) group.crew = 0;
|
||||
else if (row?.count === group.count) group.crew = row.crew;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample]));
|
||||
const items: ReturnType<typeof summarizeNationPeriod>[] = [];
|
||||
for (let period = start; period <= end; period += width) {
|
||||
@@ -166,6 +226,17 @@ export const nationSeries = auditProcedure
|
||||
}
|
||||
items.push(summarizeNationPeriod(period, width, months));
|
||||
}
|
||||
return { ...world, items, nextCursor: end < to ? dateOf(start + input.limit * width) : null };
|
||||
const currentState =
|
||||
world.serverId && end === current
|
||||
? await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } })
|
||||
: null;
|
||||
return {
|
||||
...world,
|
||||
items,
|
||||
currentSettlement: currentState
|
||||
? projectCurrentSettlement(currentState.meta, world.year, world.month, input.nationId)
|
||||
: null,
|
||||
nextCursor: end < to ? dateOf(start + input.limit * width) : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -117,6 +117,12 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
|
||||
startYear,
|
||||
startMonth,
|
||||
collectionStart,
|
||||
historyGap: (() => {
|
||||
const gap = asRecord(meta.playAuditGap);
|
||||
return gap.serverId === serverId && Number.isInteger(gap.firstYear) && Number.isInteger(gap.firstMonth)
|
||||
? { firstYear: Number(gap.firstYear), firstMonth: Number(gap.firstMonth) }
|
||||
: null;
|
||||
})(),
|
||||
tick: world.lastTurnTick?.toString() ?? null,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
} from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
|
||||
if (result?.type === 'commandRejected') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
||||
}
|
||||
if (!result || result.type !== 'tournamentAdjustGeneral') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
|
||||
}
|
||||
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
|
||||
};
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
return true;
|
||||
@@ -197,6 +207,7 @@ const zMatch = z.object({
|
||||
|
||||
const zBetEntry = z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int().positive(),
|
||||
amount: z.number().int().positive(),
|
||||
});
|
||||
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
|
||||
});
|
||||
}),
|
||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, entries, matches] = await Promise.all([
|
||||
store.getState(),
|
||||
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
|
||||
}
|
||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||
totalAmount += entry.amount;
|
||||
if (entry.generalId === general.id) {
|
||||
if (entry.userId === ctx.auth!.user.id) {
|
||||
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||
myAmount += entry.amount;
|
||||
}
|
||||
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
|
||||
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -develCost,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
assertTournamentAdjustment(feeResult);
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: develCost,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||
}
|
||||
|
||||
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
|
||||
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
|
||||
if (entries.some((entry) => entry.userId === undefined)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
|
||||
}
|
||||
const previousBetAmount = entries
|
||||
.filter((entry) => entry.generalId === general.id)
|
||||
.filter((entry) => entry.userId === ctx.auth!.user.id)
|
||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (previousBetAmount + input.amount > 1_000) {
|
||||
throw new TRPCError({
|
||||
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
|
||||
}
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -input.amount,
|
||||
betGoldDelta: input.amount,
|
||||
minGoldAfter: 500,
|
||||
});
|
||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: input.amount },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
|
||||
}
|
||||
assertTournamentAdjustment(adjustResult);
|
||||
|
||||
try {
|
||||
await store.appendBettingEntry({
|
||||
generalId: general.id,
|
||||
userId: ctx.auth!.user.id,
|
||||
targetId: input.targetId,
|
||||
amount: input.amount,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: -input.amount },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: input.amount,
|
||||
betGoldDelta: -input.amount,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildRecruitmentCommandInfo,
|
||||
buildTurnCommandTable,
|
||||
evaluateReservedTurnPermission,
|
||||
resolveCommandJoinEnv,
|
||||
} from '../../turns/commandTable.js';
|
||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||
import {
|
||||
@@ -45,7 +46,10 @@ import {
|
||||
buildRefAmountPresets,
|
||||
buildRefGeneralTargetOptions,
|
||||
buildRefNationTargetOptions,
|
||||
COUNTER_STRATEGY_COMMAND_KEYS,
|
||||
} from '../../turns/commandTargets.js';
|
||||
import { resolveImpossibleStrategicCommands } from '../../services/mainNationProjection.js';
|
||||
import { resolveNationBlockScout, resolveNationScoutMessage } from '../nation/shared.js';
|
||||
|
||||
const zPushAmount = z
|
||||
.number()
|
||||
@@ -357,10 +361,20 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
|
||||
}
|
||||
}
|
||||
// Ref che_피장파장 availableCommandTypeList: 아국 nation_env next_execute_<전략명> 기준 남은 턴.
|
||||
const strategyCooldowns = Object.fromEntries(
|
||||
resolveImpossibleStrategicCommands(nation?.meta, worldState.currentYear, worldState.currentMonth).map(
|
||||
(entry) => [`che_${entry.name}`, entry.remainingTurns]
|
||||
)
|
||||
);
|
||||
const nationTargetOptions = buildRefNationTargetOptions({
|
||||
actorNationId: general.nationId,
|
||||
join: { actorNpcState: general.npcState, ...resolveCommandJoinEnv(worldState) },
|
||||
strategyAvailable: COUNTER_STRATEGY_COMMAND_KEYS.some((key) => strategyCooldowns[key] === undefined),
|
||||
nations: nations.map((entry) => {
|
||||
const relation = diplomacyByNation.get(entry.id);
|
||||
const meta = asRecord(entry.meta);
|
||||
const gennum = readGeneralMetaNumber(meta, 'gennum');
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
@@ -374,6 +388,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
diplomacyTerm: relation?.term ?? 0,
|
||||
adjacent: adjacentNationIds.has(entry.id),
|
||||
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
|
||||
...(gennum === null ? {} : { gennum }),
|
||||
blockScout: resolveNationBlockScout(meta),
|
||||
scoutMessage: resolveNationScoutMessage(meta),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -398,6 +415,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
item: general.itemCode,
|
||||
},
|
||||
});
|
||||
const dexterityByArmType: Record<string, number> = {};
|
||||
const inputOptions: TurnCommandInputOptions = {
|
||||
cities: cities.map((entry) => ({
|
||||
value: entry.id,
|
||||
@@ -406,6 +424,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
})),
|
||||
nations: nationTargetOptions.nations,
|
||||
nationTargets: nationTargetOptions.nationTargets,
|
||||
nationScoutMessages: nationTargetOptions.nationScoutMessages,
|
||||
strategyCooldowns,
|
||||
generals: generalTargetOptions.generals,
|
||||
generalTargets: generalTargetOptions.generalTargets,
|
||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||
@@ -413,6 +433,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
|
||||
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
||||
if (dexterity !== null) dexterityByArmType[value] = dexterity;
|
||||
return {
|
||||
value: Number(value),
|
||||
label,
|
||||
@@ -449,6 +470,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
actorRice: general.rice,
|
||||
...(city ? { citySecurity: city.security } : {}),
|
||||
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
||||
dexterity: dexterityByArmType,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js';
|
||||
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
||||
import {
|
||||
formatGeneralAccessLimitMessage,
|
||||
@@ -339,11 +340,23 @@ export const yearbookRouter = router({
|
||||
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
||||
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
||||
const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth;
|
||||
// Ref v_history.php resolves an archived generation's map from ng_games.map.
|
||||
const mapLayout = target.isCurrentProfile
|
||||
? await loadMapLayout(ctx.profile.scenario)
|
||||
: await loadMapLayoutByName(
|
||||
(
|
||||
await ctx.db.gameHistory.findUnique({
|
||||
where: { serverId: target.archiveKey },
|
||||
select: { map: true },
|
||||
})
|
||||
)?.map || 'che'
|
||||
);
|
||||
|
||||
return {
|
||||
firstYearMonth,
|
||||
lastYearMonth,
|
||||
currentYearMonth: selectedYearMonth,
|
||||
mapLayout,
|
||||
};
|
||||
}),
|
||||
getHistory: authedProcedure
|
||||
|
||||
+188
-113
@@ -1,8 +1,10 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -21,7 +23,7 @@ import {
|
||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||
import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||
import {
|
||||
consumeRealtimeAccessGrantHeader,
|
||||
@@ -32,6 +34,9 @@ import { appRouter } from './router.js';
|
||||
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||
import { AuthenticatedRealtimeConnection } from './realtime/authenticatedConnection.js';
|
||||
import { RealtimeConnectionAdmission } from './realtime/connectionAdmission.js';
|
||||
import { formatSseFrame } from './realtime/sse.js';
|
||||
import {
|
||||
shouldForwardRealtimeEvent,
|
||||
@@ -51,7 +56,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||
import { scopeHttpIdempotencyKey } from './requestId.js';
|
||||
import { loadClockReadiness } from './services/clockReadiness.js';
|
||||
import { loadProfileReadiness } from './services/clockReadiness.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -71,16 +76,22 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
||||
const resolveAuthFromToken = async (
|
||||
token: string | null,
|
||||
accessTokenStore: RedisAccessTokenStore,
|
||||
flushStore: FlushStore
|
||||
flushStore: FlushStore,
|
||||
profileName: string
|
||||
): Promise<GameSessionTokenPayload | null> => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const stored = await accessTokenStore.get(token);
|
||||
if (!stored) {
|
||||
if (!stored || stored.profile !== profileName) {
|
||||
return null;
|
||||
}
|
||||
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
||||
let flushedAt: Date | null;
|
||||
try {
|
||||
flushedAt = await flushStore.getFlushedAt(stored.user.id);
|
||||
} catch {
|
||||
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' });
|
||||
}
|
||||
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
||||
return null;
|
||||
}
|
||||
@@ -99,11 +110,18 @@ export const createGameApiServer = async () => {
|
||||
imageUploadSecret
|
||||
);
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
logger: safeHttpLoggerOptions,
|
||||
routerOptions: {
|
||||
maxParamLength: 2048,
|
||||
},
|
||||
});
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
|
||||
const realtimeAdmission = new RealtimeConnectionAdmission();
|
||||
app.addHook('preClose', async () => {
|
||||
realtimeAdmission.close();
|
||||
for (const connection of realtimeConnections) connection.close();
|
||||
});
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
@@ -134,7 +152,18 @@ export const createGameApiServer = async () => {
|
||||
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||
});
|
||||
const flushStore = new InMemoryFlushStore();
|
||||
const flushStore = new RedisFlushStore(redis.client, config.flushChannel);
|
||||
try {
|
||||
await flushStore.assertInitialized();
|
||||
} catch {
|
||||
await Promise.allSettled([
|
||||
Promise.resolve().then(() => {
|
||||
if (redis.client.isOpen) redis.client.destroy();
|
||||
}),
|
||||
postgres.disconnect(),
|
||||
]);
|
||||
throw new Error('Gateway session revocation is unavailable; game API startup refused.');
|
||||
}
|
||||
const flushSubscriberClient = redis.client.duplicate();
|
||||
try {
|
||||
await flushSubscriberClient.connect();
|
||||
@@ -150,6 +179,12 @@ export const createGameApiServer = async () => {
|
||||
config.flushChannel,
|
||||
flushStore,
|
||||
async (event) => {
|
||||
const flushedAt = Date.parse(event.flushedAt);
|
||||
for (const connection of realtimeConnections) {
|
||||
if (connection.auth.user.id === event.userId && Date.parse(connection.auth.issuedAt) <= flushedAt) {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
|
||||
},
|
||||
(error, event) => {
|
||||
@@ -239,8 +274,9 @@ export const createGameApiServer = async () => {
|
||||
|
||||
app.addHook('onClose', closeResources);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -256,7 +292,7 @@ export const createGameApiServer = async () => {
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
||||
? req.headers['idempotency-key'][0]
|
||||
: req.headers['idempotency-key'];
|
||||
@@ -299,80 +335,124 @@ export const createGameApiServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
if (
|
||||
!auth ||
|
||||
auth.profile !== config.profileName ||
|
||||
isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile])
|
||||
)
|
||||
return null;
|
||||
return auth;
|
||||
};
|
||||
|
||||
app.get(config.eventsPath, async (request, reply) => {
|
||||
const query = request.query as { token?: string; scope?: string };
|
||||
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
||||
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
||||
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
|
||||
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
|
||||
|
||||
if (!auth) {
|
||||
if (query.token !== undefined || !tokenFromHeader) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
where: { userId: auth.user.id, npcState: 0 },
|
||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||
});
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
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' });
|
||||
}
|
||||
const nation =
|
||||
general.nationId > 0
|
||||
? await postgres.prisma.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
generalId: general.id,
|
||||
cityId: general.cityId,
|
||||
nationId: general.nationId,
|
||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
|
||||
reply.hijack();
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
|
||||
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader('Vary', 'Origin');
|
||||
});
|
||||
if (!opening) {
|
||||
await reply.header('Retry-After', '15').status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||
return;
|
||||
}
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
request.raw.setTimeout(0);
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const sendFrame = (payload: string) => {
|
||||
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 {
|
||||
reply.raw.write(payload);
|
||||
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;
|
||||
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ready',
|
||||
data: '{}',
|
||||
})
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let eventQueue = Promise.resolve();
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
eventQueue = eventQueue
|
||||
.then(async () => {
|
||||
if (closed) return;
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
where: { userId: auth.user.id, npcState: 0 },
|
||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||
});
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
}
|
||||
const nation =
|
||||
general.nationId > 0
|
||||
? await postgres.prisma.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
generalId: general.id,
|
||||
cityId: general.cityId,
|
||||
nationId: general.nationId,
|
||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
|
||||
|
||||
reply.hijack();
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
|
||||
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader('Vary', 'Origin');
|
||||
}
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
request.raw.setTimeout(0);
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
|
||||
resolveRealtimeAuth(tokenFromHeader)
|
||||
);
|
||||
realtimeConnections.add(connection);
|
||||
connection.onClose(() => {
|
||||
realtimeConnections.delete(connection);
|
||||
// A disconnected socket can still own an in-flight Redis/SQL operation.
|
||||
void connection.whenIdle().then(opening.release, opening.release);
|
||||
});
|
||||
transferred = true;
|
||||
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
connection.enqueue(async (currentAuth) => {
|
||||
const identities = [viewerIdentity];
|
||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||
const nextIdentity = await loadViewerIdentity();
|
||||
@@ -381,69 +461,64 @@ export const createGameApiServer = async () => {
|
||||
}
|
||||
let refreshGrant: string | undefined;
|
||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
|
||||
refreshGrant ??= createRealtimeAccessGrant(
|
||||
currentAuth,
|
||||
config.profileName,
|
||||
config.gameTokenSecret
|
||||
);
|
||||
return refreshGrant;
|
||||
});
|
||||
if (!publicEvent || closed) return;
|
||||
if (!publicEvent || connection.closed) return null;
|
||||
if (refreshGrant) {
|
||||
try {
|
||||
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
|
||||
} catch {
|
||||
// Preserve the invalidation. An unregistered grant safely falls back
|
||||
// to the normal scored refresh path.
|
||||
// An unregistered grant falls back to the scored refresh path.
|
||||
}
|
||||
}
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: publicEvent.type,
|
||||
data: JSON.stringify(publicEvent),
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// A best-effort notification must not affect committed game state.
|
||||
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
|
||||
});
|
||||
});
|
||||
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending) return;
|
||||
heartbeatPending = true;
|
||||
void turnEngineStatus
|
||||
.get()
|
||||
.then((turnEngineRunning) => {
|
||||
if (closed) return;
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
});
|
||||
connection.onClose(unsubscribe);
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending || connection.closed) return;
|
||||
heartbeatPending = true;
|
||||
connection.enqueue(async () => {
|
||||
try {
|
||||
return formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ turnEngineRunning }),
|
||||
})
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
heartbeatPending = false;
|
||||
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
|
||||
});
|
||||
} finally {
|
||||
heartbeatPending = false;
|
||||
}
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
request.raw.on('close', close);
|
||||
request.raw.on('aborted', close);
|
||||
}, 15_000);
|
||||
connection.onClose(() => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
request.raw.once('aborted', connection.close);
|
||||
connection.onClose(() => {
|
||||
request.raw.off('aborted', connection.close);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!expired && !disconnected) throw error;
|
||||
} finally {
|
||||
request.raw.off('aborted', cancelOpening);
|
||||
reply.raw.off('close', cancelOpening);
|
||||
if (!transferred) opening.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_request, reply) => {
|
||||
const clock = await loadClockReadiness(postgres.prisma);
|
||||
if (!clock.reconciliationComplete) reply.code(503);
|
||||
const readiness = await loadProfileReadiness(postgres.prisma, config.profileName);
|
||||
if (!readiness.ok) reply.code(503);
|
||||
return {
|
||||
ok: clock.reconciliationComplete,
|
||||
...readiness,
|
||||
profile: config.profileName,
|
||||
postgresPool: postgres.getPoolStats(),
|
||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||
clock,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
@@ -90,3 +91,20 @@ export const loadClockAdminStatus = async (db: DatabaseClient) => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** API 기동과 별개로 데몬의 초기 감사 저장 및 clock 복구가 끝나야 배포 준비 완료다. */
|
||||
export const loadProfileReadiness = async (db: DatabaseClient, profileName: string) => {
|
||||
const [clock, leases] = await Promise.all([
|
||||
loadClockReadiness(db),
|
||||
db.$queryRaw<Array<{ ready: boolean }>>(GamePrisma.sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM turn_daemon_lease
|
||||
WHERE profile = ${profileName}
|
||||
AND clock_ready = TRUE
|
||||
AND lease_until > (clock_timestamp() AT TIME ZONE 'UTC')
|
||||
) AS ready
|
||||
`),
|
||||
]);
|
||||
const daemonReady = leases[0]?.ready === true;
|
||||
return { ok: clock.reconciliationComplete && daemonReady, clock, daemonReady };
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GamePrisma,
|
||||
hashAuditDiplomacyDocument,
|
||||
persistAuditDiplomacyEvents,
|
||||
withPlayAuditSavepoint,
|
||||
projectAuditDocumentState,
|
||||
readTurnRuntimeReady,
|
||||
type AuditDiplomacyEventDraft,
|
||||
@@ -83,6 +84,7 @@ const readCoordinate = async (ctx: GameApiContext) => {
|
||||
};
|
||||
|
||||
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
|
||||
let collectionFailed = false;
|
||||
const changes: {
|
||||
letter: Letter;
|
||||
before: Record<string, unknown> | null;
|
||||
@@ -92,48 +94,55 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
|
||||
return {
|
||||
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
|
||||
if (!ctx.auditInput) return;
|
||||
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||
try {
|
||||
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||
} catch {
|
||||
collectionFailed = true;
|
||||
}
|
||||
},
|
||||
flush: async (): Promise<void> => {
|
||||
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
|
||||
const input = ctx.auditInput;
|
||||
if (!input || !changes.length) return;
|
||||
if (!input || (!changes.length && !collectionFailed)) return;
|
||||
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
|
||||
throw new Error('Play audit diplomacy actor mismatch');
|
||||
const coordinate = await readCoordinate(ctx);
|
||||
if (!coordinate) return;
|
||||
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||
schemaVersion: 1,
|
||||
serverId: coordinate.serverId,
|
||||
srcNationId: change.letter.srcNationId,
|
||||
destNationId: change.letter.destNationId,
|
||||
category: 'DOCUMENT',
|
||||
source: 'API',
|
||||
eventType: change.eventType,
|
||||
documentId: change.letter.id,
|
||||
documentHash: hashAuditDiplomacyDocument(change.letter),
|
||||
previousDocumentId: change.letter.prevId,
|
||||
year: coordinate.year,
|
||||
month: coordinate.month,
|
||||
tick: coordinate.tick,
|
||||
clockRevision: coordinate.clockRevision,
|
||||
executionId: `api:${input.requestId}`,
|
||||
ordinal: index + 1,
|
||||
requestId: input.requestId,
|
||||
inputSequence: input.sequence,
|
||||
actor: {
|
||||
userId: actor.userId,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission,
|
||||
},
|
||||
before: change.before,
|
||||
after: change.after,
|
||||
}));
|
||||
await persistAuditDiplomacyEvents(ctx.db, events);
|
||||
await withPlayAuditSavepoint(ctx.db, async (auditDb) => {
|
||||
if (collectionFailed) throw new Error('Audit document collection failed');
|
||||
const coordinate = await readCoordinate({ ...ctx, db: auditDb });
|
||||
if (!coordinate) return;
|
||||
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||
schemaVersion: 1,
|
||||
serverId: coordinate.serverId,
|
||||
srcNationId: change.letter.srcNationId,
|
||||
destNationId: change.letter.destNationId,
|
||||
category: 'DOCUMENT',
|
||||
source: 'API',
|
||||
eventType: change.eventType,
|
||||
documentId: change.letter.id,
|
||||
documentHash: hashAuditDiplomacyDocument(change.letter),
|
||||
previousDocumentId: change.letter.prevId,
|
||||
year: coordinate.year,
|
||||
month: coordinate.month,
|
||||
tick: coordinate.tick,
|
||||
clockRevision: coordinate.clockRevision,
|
||||
executionId: `api:${input.requestId}`,
|
||||
ordinal: index + 1,
|
||||
requestId: input.requestId,
|
||||
inputSequence: input.sequence,
|
||||
actor: {
|
||||
userId: actor.userId,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission,
|
||||
},
|
||||
before: change.before,
|
||||
after: change.after,
|
||||
}));
|
||||
await persistAuditDiplomacyEvents(auditDb, events);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||
|
||||
export interface ContentImageUploadResult {
|
||||
publicUrl: string;
|
||||
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
|
||||
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
||||
throw new Error('Invalid content image filename.');
|
||||
}
|
||||
const pathname = `/v1/uploads/content/core2026/${input.filename}`;
|
||||
const expires = String(Math.floor(this.now() / 1000) + 60);
|
||||
const requestId = randomUUID();
|
||||
const digest = createHash('sha256').update(input.body).digest('hex');
|
||||
const signature = createHmac('sha256', this.secret)
|
||||
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
|
||||
.digest('hex');
|
||||
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
'x-image-client': 'core2026',
|
||||
'x-image-expires': expires,
|
||||
'x-image-request-id': requestId,
|
||||
'x-image-signature': signature,
|
||||
await performSignedImageUpload(
|
||||
{
|
||||
...input,
|
||||
kind: 'content',
|
||||
baseUrl: this.baseUrl,
|
||||
secret: this.secret,
|
||||
},
|
||||
body: new Uint8Array(input.body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
||||
}
|
||||
const expectedPath = `uploads/core2026/${input.filename}`;
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) {
|
||||
throw new Error('Image repository returned an unexpected content path.');
|
||||
}
|
||||
this.fetchImpl,
|
||||
this.now
|
||||
);
|
||||
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
@@ -286,7 +287,10 @@ export class TournamentStore {
|
||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||
const entries = await this.getBettingEntries();
|
||||
const existing = entries.find(
|
||||
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
|
||||
(candidate) =>
|
||||
candidate.generalId === entry.generalId &&
|
||||
candidate.targetId === entry.targetId &&
|
||||
candidate.userId === entry.userId
|
||||
);
|
||||
if (existing) {
|
||||
existing.amount += entry.amount;
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
|
||||
|
||||
export interface TournamentBetEntry {
|
||||
generalId: number;
|
||||
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
|
||||
userId?: string | null;
|
||||
targetId: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
|
||||
for (const npc of npcBetList) {
|
||||
const targetId = rng.choice(candidateIds);
|
||||
if (!existingBettors.has(npc.id as number)) {
|
||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
||||
entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
|
||||
}
|
||||
|
||||
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: `${requestPrefix}:resources`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
|
||||
});
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: `${requestPrefix}:meta`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
|
||||
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
|
||||
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
|
||||
const result = await daemonTransport.requestCommand({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: `${requestPrefix}:atomic`,
|
||||
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
|
||||
});
|
||||
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
|
||||
throw new Error('NPC tournament betting did not commit.');
|
||||
}
|
||||
const processed = new Set(result.processedGeneralIds);
|
||||
const existing = await store.getBettingEntries();
|
||||
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
|
||||
await store.setBettingEntries(
|
||||
existing.concat(
|
||||
plan
|
||||
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
|
||||
.map((entry) => ({ ...entry, userId: null }))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
|
||||
@@ -20,10 +21,10 @@ import {
|
||||
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
||||
import { recordGeneralActivity } from './services/generalActivity.js';
|
||||
|
||||
const t = initTRPC.context<GameApiContext>().create();
|
||||
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface TurnCommandOption {
|
||||
crew?: number;
|
||||
troopId?: number;
|
||||
npcState?: number;
|
||||
/** 등용·장수대상임관처럼 여러 국가 장수를 고르는 목록의 국가별 묶음 기준. */
|
||||
nationId?: number;
|
||||
}
|
||||
|
||||
export interface TurnCommandAmountPreset {
|
||||
@@ -97,10 +99,21 @@ export interface TurnCommandInputField {
|
||||
tupleLabels?: string[];
|
||||
}
|
||||
|
||||
/** 임관 권유문. message는 서버에서 purifyNationHtml로 정제한 HTML이다. */
|
||||
export interface TurnCommandNationScoutMessage {
|
||||
nationId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TurnCommandInputOptions {
|
||||
cities: TurnCommandOption[];
|
||||
nations: TurnCommandOption[];
|
||||
nationTargets?: Record<string, TurnCommandOption[]>;
|
||||
nationScoutMessages?: TurnCommandNationScoutMessage[];
|
||||
/** 아국 전략 명령별 남은 재사용 대기 턴. 대기 중인 전략만 담는다. */
|
||||
strategyCooldowns?: Record<string, number>;
|
||||
generals: TurnCommandOption[];
|
||||
generalTargets?: Record<string, TurnCommandOption[]>;
|
||||
crewTypes: TurnCommandOption[];
|
||||
@@ -117,6 +130,8 @@ export interface TurnCommandInputOptions {
|
||||
nationGold?: number;
|
||||
nationRice?: number;
|
||||
nationLevel?: number;
|
||||
/** 숙련전환 전/후 미리보기용 본인 병과별 숙련(`dex<armType>`). */
|
||||
dexterity?: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,6 +166,8 @@ export const buildEquipmentTradeItemOptions = (options: {
|
||||
{
|
||||
value: 'None',
|
||||
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
|
||||
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 붉은색 `(불가)`로 표시한다.
|
||||
...(ownedItem ? {} : { availableNow: false }),
|
||||
description: ownedItem
|
||||
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
|
||||
: ownedCode && ownedCode !== 'None'
|
||||
@@ -177,6 +194,7 @@ export const buildEquipmentTradeItemOptions = (options: {
|
||||
items[item.slot].push({
|
||||
value: item.key,
|
||||
label: item.name,
|
||||
availableNow: options.currentSecurity >= item.reqSecu && options.generalGold >= cost,
|
||||
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||
});
|
||||
}
|
||||
@@ -229,8 +247,9 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
nationName: '국가명',
|
||||
nationType: '국가 성향',
|
||||
colorType: '국기 색상',
|
||||
srcArmType: '기존 병과',
|
||||
destArmType: '변경 병과',
|
||||
// 숙련전환 전용. Ref che_숙련전환.vue의 '감소 대상 숙련 :'·'전환 대상 숙련 :'.
|
||||
srcArmType: '감소 대상 숙련',
|
||||
destArmType: '전환 대상 숙련',
|
||||
itemType: '장비 종류',
|
||||
itemCode: '장비',
|
||||
crewType: '병종',
|
||||
@@ -240,7 +259,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
optionText: '행동',
|
||||
year: '기간(년)',
|
||||
month: '기간(월)',
|
||||
commandType: '대응 명령',
|
||||
// 피장파장 전용. Ref che_피장파장.vue의 '전략 :'.
|
||||
commandType: '전략',
|
||||
amountList: '지원 물자',
|
||||
};
|
||||
|
||||
@@ -266,10 +286,14 @@ const STATIC_LABELS: Record<string, Record<string, string>> = {
|
||||
item: '도구',
|
||||
},
|
||||
commandType: {
|
||||
che_선전포고: '선전포고',
|
||||
che_불가침제의: '불가침 제의',
|
||||
che_불가침파기제의: '불가침 파기 제의',
|
||||
che_종전제의: '종전 제의',
|
||||
// 피장파장 대상 전략. Ref availableCommandTypeList의 name(getName()).
|
||||
che_필사즉생: '필사즉생',
|
||||
che_백성동원: '백성동원',
|
||||
che_수몰: '수몰',
|
||||
che_허보: '허보',
|
||||
che_의병모집: '의병모집',
|
||||
che_이호경식: '이호경식',
|
||||
che_급습: '급습',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -479,6 +479,19 @@ const mapCityRow = (row: CityRow): City => {
|
||||
};
|
||||
};
|
||||
|
||||
/** 명령표의 임관 대상 표시에 쓰는 Ref AllowJoinDestNation 시점 값. */
|
||||
export const resolveCommandJoinEnv = (
|
||||
worldState: WorldStateRow
|
||||
): { relYear: number; openingPartYear: number; initialNationGenLimit: number } => {
|
||||
const constValues = asRecord(asRecord(worldState.config).const);
|
||||
const env = buildConstraintEnv(worldState);
|
||||
return {
|
||||
relYear: Number(env.relYear),
|
||||
openingPartYear: Number(env.openingPartYear),
|
||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
const mapNationRow = (row: NationRow): Nation => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCommandAmountPreset, TurnCommandOption } from './commandInput.js';
|
||||
import type { TurnCommandAmountPreset, TurnCommandNationScoutMessage, TurnCommandOption } from './commandInput.js';
|
||||
|
||||
export interface GeneralTargetSource {
|
||||
id: number;
|
||||
@@ -33,6 +33,20 @@ export interface NationTargetSource {
|
||||
diplomacyTerm: number;
|
||||
adjacent: boolean;
|
||||
diplomacyRestricted?: boolean;
|
||||
/** Ref nation.gennum. 없으면 generalCount를 쓴다. */
|
||||
gennum?: number;
|
||||
/** 임관 금지(nation.scout). */
|
||||
blockScout?: boolean;
|
||||
/** 서버에서 purifyNationHtml로 정제한 임관 권유문. */
|
||||
scoutMessage?: string;
|
||||
}
|
||||
|
||||
/** Ref AllowJoinDestNation이 쓰는 actor·시점 정보. */
|
||||
export interface NationJoinContext {
|
||||
actorNpcState: number;
|
||||
relYear: number;
|
||||
openingPartYear: number;
|
||||
initialNationGenLimit: number;
|
||||
}
|
||||
|
||||
export interface RefGeneralTargetOptions {
|
||||
@@ -43,11 +57,21 @@ export interface RefGeneralTargetOptions {
|
||||
export interface RefNationTargetOptions {
|
||||
nations: TurnCommandOption[];
|
||||
nationTargets: Record<string, TurnCommandOption[]>;
|
||||
nationScoutMessages: TurnCommandNationScoutMessage[];
|
||||
}
|
||||
|
||||
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
||||
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
|
||||
|
||||
const statsText = (entry: GeneralTargetSource): string =>
|
||||
[
|
||||
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
||||
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
||||
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
|
||||
export const buildRefGeneralTargetOptions = (options: {
|
||||
actorId: number;
|
||||
@@ -64,29 +88,30 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
const isTroopExit = action === 'che_부대탈퇴지시';
|
||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||
const label = (() => {
|
||||
if (action === 'che_발령') return `${entry.name} (${cityName})`;
|
||||
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
||||
// 같은 국가 후보만 있는 명령은 국명을 반복하지 않는다.
|
||||
if (['che_발령', 'che_포상', 'che_몰수', 'che_선양'].includes(action ?? '')) {
|
||||
return `${entry.name} (${cityName})`;
|
||||
}
|
||||
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||
})();
|
||||
const details = [
|
||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
// Ref che_선양.vue는 후보에 (통/무/지)만 보인다.
|
||||
const details = (
|
||||
action === 'che_선양'
|
||||
? []
|
||||
: [
|
||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||
]
|
||||
).filter((value): value is string => Boolean(value));
|
||||
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
||||
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
||||
const assignmentDetails =
|
||||
action === 'che_발령'
|
||||
? [
|
||||
[
|
||||
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
||||
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
||||
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
statsText(entry),
|
||||
[
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
@@ -104,6 +129,9 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: undefined;
|
||||
// Ref ProcessGeneralAmount·ProcessGeneral은 같은 국가 후보에 (통/무/지)를 함께 보인다.
|
||||
const stats = statsText(entry);
|
||||
if (stats && action !== 'che_발령') details.push(stats);
|
||||
if (isTroopExit) {
|
||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||
}
|
||||
@@ -125,33 +153,48 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
npcState: entry.npcState,
|
||||
};
|
||||
};
|
||||
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
||||
options.generals.filter(predicate).map((entry) => toOption(entry));
|
||||
// Ref che_등용·che_장수대상임관 exportJSVars()는 타국·재야 장수의
|
||||
// no,name,nation,officer_level,npc,leadership,strength,intel만 내보낸다.
|
||||
// 명령표는 모든 장수에게 전달되므로 금·쌀·병력·훈련·사기·부대·위치 도시를 싣지 않는다.
|
||||
const toPublicOption = (entry: GeneralTargetSource): TurnCommandOption => {
|
||||
const nationName = options.nationNames.get(entry.nationId) ?? null;
|
||||
return {
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationName ?? '재야'})`,
|
||||
targetNames: { name: entry.name, nationName },
|
||||
description: statsText(entry),
|
||||
npcState: entry.npcState,
|
||||
nationId: entry.nationId,
|
||||
};
|
||||
};
|
||||
const projectPublic = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
||||
options.generals.filter(predicate).map(toPublicOption);
|
||||
// 재야는 국가가 아니므로 같은 국가 명령 후보가 없다. 해당 명령도 NotBeNeutral로 막힌다.
|
||||
const isSameNation = (entry: GeneralTargetSource): boolean =>
|
||||
options.actorNationId !== 0 && entry.nationId === options.actorNationId;
|
||||
|
||||
const generalTargets: Record<string, TurnCommandOption[]> = {};
|
||||
for (const action of SAME_NATION_GENERAL_COMMANDS) {
|
||||
generalTargets[action] = options.generals
|
||||
.filter((entry) => entry.nationId === options.actorNationId)
|
||||
.map((entry) => toOption(entry, action));
|
||||
generalTargets[action] = options.generals.filter(isSameNation).map((entry) => toOption(entry, action));
|
||||
}
|
||||
for (const action of SAME_NATION_NATION_COMMANDS) {
|
||||
generalTargets[action] = options.generals
|
||||
.filter((entry) => entry.nationId === options.actorNationId)
|
||||
.filter(isSameNation)
|
||||
.map((entry) => toOption(entry, action))
|
||||
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
|
||||
}
|
||||
|
||||
generalTargets.che_선양 = project(
|
||||
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
|
||||
);
|
||||
generalTargets.che_등용 = project(
|
||||
generalTargets.che_선양 = options.generals
|
||||
.filter((entry) => isSameNation(entry) && entry.id !== options.actorId)
|
||||
.map((entry) => toOption(entry, 'che_선양'));
|
||||
generalTargets.che_등용 = projectPublic(
|
||||
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
|
||||
);
|
||||
generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId);
|
||||
generalTargets.che_장수대상임관 = projectPublic((entry) => entry.id !== options.actorId);
|
||||
|
||||
return {
|
||||
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
|
||||
generals: project((entry) => entry.npcState < 2),
|
||||
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다. 타국 장수가 섞이므로 공개 필드만 둔다.
|
||||
generals: projectPublic((entry) => entry.npcState < 2),
|
||||
generalTargets,
|
||||
};
|
||||
};
|
||||
@@ -169,14 +212,63 @@ const NATION_TARGET_COMMANDS = [
|
||||
'che_선전포고',
|
||||
'che_종전제의',
|
||||
'che_불가침파기제의',
|
||||
'che_피장파장',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
] as const;
|
||||
|
||||
const nationAvailability = (
|
||||
action: (typeof NATION_TARGET_COMMANDS)[number],
|
||||
/** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */
|
||||
export const COUNTER_STRATEGY_COMMAND_KEYS = [
|
||||
'che_필사즉생',
|
||||
'che_백성동원',
|
||||
'che_수몰',
|
||||
'che_허보',
|
||||
'che_의병모집',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
] as const;
|
||||
|
||||
/** Ref che_임관 exportJSVars()의 notAvailable: AllowJoinDestNation의 대상 국가 조건. */
|
||||
const joinAvailability = (
|
||||
join: NationJoinContext,
|
||||
actorNationId: number,
|
||||
target: NationTargetSource
|
||||
): { available: boolean; reason: string } => {
|
||||
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
||||
const gennum = target.gennum ?? target.generalCount;
|
||||
if (join.relYear < join.openingPartYear && join.initialNationGenLimit > 0 && gennum >= join.initialNationGenLimit) {
|
||||
return { available: false, reason: '임관이 제한되고 있습니다.' };
|
||||
}
|
||||
if (target.blockScout) return { available: false, reason: '임관이 금지되어 있습니다.' };
|
||||
if (join.actorNpcState < 2 && target.name.startsWith('ⓤ')) {
|
||||
return { available: false, reason: '유저장은 태수국에 임관할 수 없습니다.' };
|
||||
}
|
||||
if (join.actorNpcState !== 9 && target.name.startsWith('ⓞ')) {
|
||||
return { available: false, reason: '이민족 국가에 임관할 수 없습니다.' };
|
||||
}
|
||||
return { available: true, reason: '현재 임관 가능' };
|
||||
};
|
||||
|
||||
const nationAvailability = (
|
||||
action: (typeof NATION_TARGET_COMMANDS)[number],
|
||||
actorNationId: number,
|
||||
target: NationTargetSource,
|
||||
strategyAvailable: boolean
|
||||
): { available: boolean; reason: string } => {
|
||||
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
||||
// Ref 피장파장은 쓸 수 있는 전략이 하나도 없으면 모든 국가를 불가로 표시한다.
|
||||
if (action === 'che_피장파장' && !strategyAvailable) {
|
||||
return { available: false, reason: '사용할 수 있는 전략이 없습니다.' };
|
||||
}
|
||||
if (action === 'che_피장파장' || action === 'che_이호경식') {
|
||||
const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
|
||||
return { available, reason: available ? '현재 발동 가능' : '선포, 전쟁중인 상대국에게만 가능합니다.' };
|
||||
}
|
||||
if (action === 'che_급습') {
|
||||
// Ref AllowDiplomacyWithTerm(1, 12): 선포 상태이고 남은 기간이 12개월 이상.
|
||||
const available = target.diplomacyState === DIPLOMACY_STATE.DECLARATION && target.diplomacyTerm >= 12;
|
||||
return { available, reason: available ? '현재 발동 가능' : '선포 12개월 이상인 상대국에만 가능합니다.' };
|
||||
}
|
||||
if (action === 'che_물자원조') {
|
||||
return target.diplomacyRestricted
|
||||
? { available: false, reason: '상대국이 외교제한 중입니다.' }
|
||||
@@ -205,7 +297,12 @@ const nationAvailability = (
|
||||
export const buildRefNationTargetOptions = (options: {
|
||||
actorNationId: number;
|
||||
nations: readonly NationTargetSource[];
|
||||
/** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */
|
||||
join?: NationJoinContext;
|
||||
/** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */
|
||||
strategyAvailable?: boolean;
|
||||
}): RefNationTargetOptions => {
|
||||
const strategyAvailable = options.strategyAvailable ?? true;
|
||||
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
@@ -217,7 +314,7 @@ export const buildRefNationTargetOptions = (options: {
|
||||
for (const action of NATION_TARGET_COMMANDS) {
|
||||
nationTargets[action] = options.nations
|
||||
.map((entry) => {
|
||||
const availability = nationAvailability(action, options.actorNationId, entry);
|
||||
const availability = nationAvailability(action, options.actorNationId, entry, strategyAvailable);
|
||||
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
|
||||
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
|
||||
return {
|
||||
@@ -238,7 +335,31 @@ export const buildRefNationTargetOptions = (options: {
|
||||
)
|
||||
.map(({ power: _power, ...entry }) => entry);
|
||||
}
|
||||
return { nations: baseOptions, nationTargets };
|
||||
const join = options.join;
|
||||
if (join) {
|
||||
// Ref 임관 목록은 nation 표 순서 그대로이며 불가 국가도 남긴다. 재야(0)는 국가가 아니다.
|
||||
nationTargets.che_임관 = options.nations.flatMap((entry, index) => {
|
||||
if (entry.id <= 0) return [];
|
||||
const availability = joinAvailability(join, options.actorNationId, entry);
|
||||
return [
|
||||
{
|
||||
...baseOptions[index]!,
|
||||
availableNow: availability.available,
|
||||
description: `${availability.reason} · ${baseOptions[index]!.description}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
// Ref che_임관·che_장수대상임관은 모든 국가의 권유문(scout_msg)을 함께 내보낸다.
|
||||
const nationScoutMessages = options.nations
|
||||
.filter((entry) => entry.id > 0)
|
||||
.map((entry) => ({
|
||||
nationId: entry.id,
|
||||
name: entry.name,
|
||||
color: entry.color,
|
||||
message: entry.scoutMessage ?? '',
|
||||
}));
|
||||
return { nations: baseOptions, nationTargets, nationScoutMessages };
|
||||
};
|
||||
|
||||
const RESOURCE_ACTION_GUIDE = [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SystemClock } from '@sammo-ts/common';
|
||||
import {
|
||||
createDatabaseTurnHooks,
|
||||
DatabaseTurnDaemonCommandQueue,
|
||||
DatabaseTurnDaemonLease,
|
||||
EngineStateManager,
|
||||
InMemoryTurnStateStore,
|
||||
InMemoryTurnWorld,
|
||||
@@ -695,6 +696,10 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
|
||||
const redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await redisConnector.connect();
|
||||
// 독립 worker는 활성 daemon의 clock_ready fence가 있을 때만 GAME_TIME을 진행한다.
|
||||
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile: profileName });
|
||||
await lease.acquire();
|
||||
await lease.markClockReady();
|
||||
const keys = buildAuctionTimerKeys(profileName);
|
||||
const abortController = new AbortController();
|
||||
const worker = runAuctionWorker({ signal: abortController.signal });
|
||||
@@ -725,6 +730,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
} finally {
|
||||
abortController.abort();
|
||||
await worker;
|
||||
await lease.close();
|
||||
await connector.prisma.turnDaemonLease.delete({ where: { profile: profileName } });
|
||||
await redisConnector.client.del([keys.timerKey, keys.historyKey]);
|
||||
await redisConnector.disconnect();
|
||||
await connector.prisma.errorLog.deleteMany({
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||
|
||||
const payload = (): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
sessionId: 'test-session',
|
||||
profile: 'che:default',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
user: {
|
||||
id: 'test-user',
|
||||
username: 'test',
|
||||
displayName: 'test',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
const fixture = (resolve: () => Promise<GameSessionTokenPayload | null>, auth = payload()) => {
|
||||
const response = new PassThrough();
|
||||
const frames: string[] = [];
|
||||
response.on('data', (chunk) => frames.push(String(chunk)));
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve);
|
||||
return { response, frames, connection };
|
||||
};
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('authenticated realtime connection', () => {
|
||||
it('does not forward an in-flight result after the token is revoked during its work', async () => {
|
||||
const auth = payload();
|
||||
let current: GameSessionTokenPayload | null = auth;
|
||||
const { connection, frames } = fixture(async () => current, auth);
|
||||
connection.enqueue(async () => {
|
||||
current = null;
|
||||
return 'private invalidation';
|
||||
});
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth);
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('fails closed when authentication storage fails', async () => {
|
||||
const { connection, frames } = fixture(async () => {
|
||||
throw new Error('redis unavailable');
|
||||
});
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends an idle expired stream and releases subscriptions only once', async () => {
|
||||
vi.useFakeTimers();
|
||||
const auth = payload();
|
||||
const { connection } = fixture(async () => auth, auth);
|
||||
const cleanup = vi.fn();
|
||||
connection.onClose(cleanup);
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
expect(connection.closed).toBe(true);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
connection.close();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('bounds queued work while an earlier event is blocked', async () => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => auth, auth);
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
connection.enqueue(async () => {
|
||||
await waiting;
|
||||
return 'private invalidation';
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation');
|
||||
expect(connection.closed).toBe(true);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends a slow socket instead of retaining further frames', async () => {
|
||||
const auth = payload();
|
||||
const { connection, response } = fixture(async () => auth, auth);
|
||||
response.write = vi.fn(() => false);
|
||||
connection.enqueue(async () => 'event: ping\ndata: {}\n\n');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(response.writableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => {
|
||||
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
||||
});
|
||||
|
||||
it('rejects highly compressed images over the decoded pixel budget before upload', async () => {
|
||||
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
expect(png.length).toBeLessThan(1024 * 1024);
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects excess concurrent image work and recovers when uploads settle', async () => {
|
||||
const pending: Array<() => void> = [];
|
||||
const upload = vi.fn(
|
||||
() =>
|
||||
new Promise<{ publicUrl: string }>((resolve) => {
|
||||
pending.push(() => resolve({ publicUrl: 'https://image.example/image.webp' }));
|
||||
})
|
||||
);
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const png = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const input = { dataUrl: `data:image/png;base64,${png.toString('base64')}` };
|
||||
const admitted = Array.from({ length: 4 }, () => caller.board.uploadImage(input));
|
||||
try {
|
||||
await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(4));
|
||||
await expect(caller.board.uploadImage(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
||||
} finally {
|
||||
pending.forEach((done) => done());
|
||||
await Promise.all(admitted);
|
||||
}
|
||||
upload.mockImplementation(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
await expect(caller.board.uploadImage(input)).resolves.toMatchObject({ format: 'webp' });
|
||||
});
|
||||
|
||||
it('rejects SVG before a native image parser or upload is used', async () => {
|
||||
const upload = vi.fn();
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||
dataUrl: Buffer.from('<svg width="64" height="64"><script>alert(1)</script></svg>').toString('base64'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects excessive animation frames while preserving ordinary GIF animation', async () => {
|
||||
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const makeGif = (pages: number) =>
|
||||
sharp(Buffer.alloc(pages * 4, 255), {
|
||||
raw: { width: 1, height: pages, channels: 4, pageHeight: 1 },
|
||||
})
|
||||
.gif({ keepDuplicateFrames: true, delay: Array(pages).fill(10) })
|
||||
.toBuffer();
|
||||
const excessive = await makeGif(257);
|
||||
expect((await sharp(excessive, { animated: true }).metadata()).pages).toBe(257);
|
||||
await expect(caller.board.uploadImage({ dataUrl: excessive.toString('base64') })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
const ordinary = await makeGif(3);
|
||||
await expect(caller.board.uploadImage({ dataUrl: ordinary.toString('base64') })).resolves.toMatchObject({
|
||||
animated: true,
|
||||
format: 'webp',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an AVIF editor image when resizing is unnecessary', async () => {
|
||||
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
||||
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js';
|
||||
import { loadClockAdminStatus, loadClockReadiness, loadProfileReadiness } from '../src/services/clockReadiness.js';
|
||||
|
||||
describe('clock reconciliation readiness', () => {
|
||||
it.each([false, true])('requires the profile daemon lease to be ready: %s', async (ready) => {
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({ clockPhase: 'PREOPEN', clockRevision: 1n, deadlineGeneration: 0n })),
|
||||
},
|
||||
clockProjectionOutbox: { count: vi.fn(async () => 0) },
|
||||
$queryRaw: vi.fn(async () => [{ ready }]),
|
||||
} as unknown as DatabaseClient;
|
||||
await expect(loadProfileReadiness(db, 'che:default')).resolves.toMatchObject({
|
||||
ok: ready,
|
||||
daemonReady: ready,
|
||||
clock: { reconciliationComplete: true, gameplayEnabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the reconciliation schema is not available', async () => {
|
||||
const db = {} as DatabaseClient;
|
||||
await expect(loadClockReadiness(db)).resolves.toEqual({
|
||||
|
||||
@@ -53,6 +53,29 @@ describe('turn command argument input', () => {
|
||||
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||
{ key: 'amountList', kind: 'numberTuple' },
|
||||
]);
|
||||
// Ref che_피장파장.vue: '전략 :' select에 자신을 뺀 전략 이름을 보인다.
|
||||
expect(fields.find((entry) => entry.key === 'che_피장파장')?.fields).toMatchObject([
|
||||
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||
{
|
||||
key: 'commandType',
|
||||
label: '전략',
|
||||
kind: 'select',
|
||||
options: [
|
||||
{ value: 'che_필사즉생', label: '필사즉생' },
|
||||
{ value: 'che_백성동원', label: '백성동원' },
|
||||
{ value: 'che_수몰', label: '수몰' },
|
||||
{ value: 'che_허보', label: '허보' },
|
||||
{ value: 'che_의병모집', label: '의병모집' },
|
||||
{ value: 'che_이호경식', label: '이호경식' },
|
||||
{ value: 'che_급습', label: '급습' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// Ref che_숙련전환.vue: '감소 대상 숙련 :', '전환 대상 숙련 :'.
|
||||
expect(fields.find((entry) => entry.key === 'che_숙련전환')?.fields).toMatchObject([
|
||||
{ key: 'srcArmType', label: '감소 대상 숙련', kind: 'select', optionSource: 'armTypes' },
|
||||
{ key: 'destArmType', label: '전환 대상 숙련', kind: 'select', optionSource: 'armTypes' },
|
||||
]);
|
||||
|
||||
expect(
|
||||
nationFields
|
||||
@@ -321,6 +344,9 @@ describe('turn command argument input', () => {
|
||||
'che_계략_향낭',
|
||||
]);
|
||||
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
||||
expect(items.item[1]?.availableNow).toBe(true);
|
||||
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 불가로 표시한다.
|
||||
expect(items.item[0]?.availableNow).toBe(false);
|
||||
});
|
||||
|
||||
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
||||
@@ -341,5 +367,6 @@ describe('turn command argument input', () => {
|
||||
|
||||
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
|
||||
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
|
||||
expect(items.item[1]?.availableNow).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildRefGeneralTargetOptions,
|
||||
buildRefNationTargetOptions,
|
||||
type GeneralTargetSource,
|
||||
type NationTargetSource,
|
||||
} from '../src/turns/commandTargets.js';
|
||||
|
||||
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
|
||||
@@ -49,8 +50,9 @@ describe('Ref command general targets', () => {
|
||||
it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => {
|
||||
const options = buildRefGeneralTargetOptions({
|
||||
actorId: 1,
|
||||
actorNationId: 0,
|
||||
generals: [general({ name: '없음', nationId: 0, cityId: 0, troopId: 99 })],
|
||||
// 이름·국가·도시·부대 원본이 없는 경우. 재야 actor는 같은 국가 후보가 없으므로 국가 ID만 둔다.
|
||||
actorNationId: 5,
|
||||
generals: [general({ name: '없음', nationId: 5, cityId: 0, troopId: 99 })],
|
||||
nationNames: new Map(),
|
||||
cityNames: new Map(),
|
||||
troopNames: new Map(),
|
||||
@@ -118,7 +120,10 @@ describe('Ref command general targets', () => {
|
||||
);
|
||||
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000');
|
||||
expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대');
|
||||
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
|
||||
// Ref ProcessGeneralAmount.vue처럼 증여 후보도 (통/무/지)를 함께 보인다. 발령의 줄바꿈 형식은 쓰지 않는다.
|
||||
expect(detailed.generalTargets.che_증여?.[1]?.description).toBe(
|
||||
'금 100 · 쌀 200 · 병력 900 · 훈련 80 · 사기 70 · 통솔 100 · 무력 95 · 지력 0'
|
||||
);
|
||||
expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([
|
||||
{ name: '본인', nationName: '아국', cityName: '업', troopName: null },
|
||||
{ name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' },
|
||||
@@ -132,6 +137,106 @@ describe('Ref command general targets', () => {
|
||||
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
|
||||
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
|
||||
});
|
||||
|
||||
it('limits cross-nation recruitment and joining targets to the fields Ref exports', () => {
|
||||
const foreign = general({
|
||||
id: 7,
|
||||
name: '타국장수',
|
||||
nationId: 2,
|
||||
cityId: 20,
|
||||
officerLevel: 1,
|
||||
gold: 9000,
|
||||
rice: 8000,
|
||||
crew: 7000,
|
||||
train: 90,
|
||||
atmos: 80,
|
||||
troopId: 7,
|
||||
leadership: 75,
|
||||
strength: 60,
|
||||
intel: 45,
|
||||
});
|
||||
const wanderer = general({ id: 8, name: '재야장수', nationId: 0, cityId: 30, gold: 500, rice: 400 });
|
||||
const options = buildRefGeneralTargetOptions({
|
||||
actorId: 1,
|
||||
actorNationId: 1,
|
||||
generals: [general({}), foreign, wanderer],
|
||||
nationNames: new Map([
|
||||
[1, '아국'],
|
||||
[2, '타국'],
|
||||
]),
|
||||
cityNames: new Map([
|
||||
[10, '업'],
|
||||
[20, '허창'],
|
||||
[30, '단양'],
|
||||
]),
|
||||
troopNames: new Map([[7, '비밀부대']]),
|
||||
});
|
||||
for (const list of [
|
||||
options.generalTargets.che_등용,
|
||||
options.generalTargets.che_장수대상임관,
|
||||
options.generals,
|
||||
]) {
|
||||
const target = list?.find((entry) => entry.value === 7);
|
||||
// Ref: no,name,nation,officer_level,npc,leadership,strength,intel
|
||||
expect(target).toEqual({
|
||||
value: 7,
|
||||
label: '타국장수 (타국)',
|
||||
targetNames: { name: '타국장수', nationName: '타국' },
|
||||
description: '통솔 75 · 무력 60 · 지력 45',
|
||||
npcState: 0,
|
||||
nationId: 2,
|
||||
});
|
||||
const serialized = JSON.stringify(list);
|
||||
for (const hidden of ['9,000', '9000', '8000', '7000', '허창', '단양', '비밀부대', '훈련', '사기']) {
|
||||
expect(serialized).not.toContain(hidden);
|
||||
}
|
||||
expect(list?.find((entry) => entry.value === 8)).toMatchObject({
|
||||
label: '재야장수 (재야)',
|
||||
targetNames: { name: '재야장수', nationName: null },
|
||||
nationId: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a wandering actor no same-nation general candidates', () => {
|
||||
const options = buildRefGeneralTargetOptions({
|
||||
actorId: 1,
|
||||
actorNationId: 0,
|
||||
generals: [general({ nationId: 0 }), general({ id: 2, name: '다른재야', nationId: 0, gold: 900 })],
|
||||
nationNames: new Map(),
|
||||
cityNames: new Map([[10, '업']]),
|
||||
});
|
||||
for (const action of ['che_증여', 'che_선양', 'che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) {
|
||||
expect(options.generalTargets[action]).toEqual([]);
|
||||
}
|
||||
expect(options.generalTargets.che_장수대상임관?.map((entry) => entry.value)).toEqual([2]);
|
||||
expect(JSON.stringify(options.generalTargets.che_장수대상임관)).not.toContain('900');
|
||||
});
|
||||
|
||||
it('shows only city and Ref stats for abdication candidates', () => {
|
||||
const options = buildRefGeneralTargetOptions({
|
||||
actorId: 1,
|
||||
actorNationId: 1,
|
||||
generals: [
|
||||
general({}),
|
||||
general({
|
||||
id: 2,
|
||||
name: '후계',
|
||||
gold: 100,
|
||||
rice: 200,
|
||||
crew: 300,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intel: 60,
|
||||
}),
|
||||
],
|
||||
nationNames: new Map([[1, '아국']]),
|
||||
cityNames: new Map([[10, '업']]),
|
||||
});
|
||||
expect(options.generalTargets.che_선양).toMatchObject([
|
||||
{ value: 2, label: '후계 (업)', description: '통솔 80 · 무력 70 · 지력 60' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ref nation target guidance', () => {
|
||||
@@ -219,6 +324,88 @@ describe('Ref nation target guidance', () => {
|
||||
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
|
||||
expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false });
|
||||
});
|
||||
|
||||
it('marks counter-strategy targets like Ref exportJSVars notAvailable', () => {
|
||||
const withDeclaration = [
|
||||
...nations,
|
||||
{ ...nations[3]!, id: 5, name: '선포국', diplomacyState: 1, diplomacyTerm: 12 },
|
||||
{ ...nations[3]!, id: 6, name: '신규선포국', diplomacyState: 1, diplomacyTerm: 11 },
|
||||
];
|
||||
const result = buildRefNationTargetOptions({ actorNationId: 1, nations: withDeclaration });
|
||||
const available = (action: string) =>
|
||||
result.nationTargets[action]?.filter((entry) => entry.availableNow).map((entry) => entry.value);
|
||||
// 피장파장·이호경식: AllowDiplomacyBetweenStatus([0, 1])
|
||||
expect(available('che_피장파장')).toEqual([4, 5, 6]);
|
||||
expect(available('che_이호경식')).toEqual([4, 5, 6]);
|
||||
// 급습: AllowDiplomacyWithTerm(1, 12)
|
||||
expect(available('che_급습')).toEqual([5]);
|
||||
expect(result.nationTargets.che_급습?.find((entry) => entry.value === 6)?.description).toContain(
|
||||
'선포 12개월 이상인 상대국에만 가능합니다.'
|
||||
);
|
||||
expect(result.nationTargets.che_피장파장?.find((entry) => entry.value === 1)).toMatchObject({
|
||||
availableNow: false,
|
||||
});
|
||||
|
||||
const exhausted = buildRefNationTargetOptions({
|
||||
actorNationId: 1,
|
||||
nations: withDeclaration,
|
||||
strategyAvailable: false,
|
||||
});
|
||||
expect(exhausted.nationTargets.che_피장파장?.every((entry) => entry.availableNow === false)).toBe(true);
|
||||
expect(exhausted.nationTargets.che_피장파장?.find((entry) => entry.value === 4)?.description).toContain(
|
||||
'사용할 수 있는 전략이 없습니다.'
|
||||
);
|
||||
expect(exhausted.nationTargets.che_이호경식?.some((entry) => entry.availableNow)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the Ref nation order for appointment targets and exports purified scout messages', () => {
|
||||
const joinNations: NationTargetSource[] = [
|
||||
{ ...nations[0]!, scoutMessage: '<p>어서 오시오</p>' },
|
||||
{ ...nations[1]!, blockScout: true },
|
||||
{ ...nations[2]!, name: 'ⓤ태수국' },
|
||||
{ ...nations[3]!, name: 'ⓞ이민족' },
|
||||
{ ...nations[3]!, id: 5, name: '초반만원국', generalCount: 3, gennum: 10 },
|
||||
];
|
||||
const join = { actorNpcState: 0, relYear: 1, openingPartYear: 3, initialNationGenLimit: 10 };
|
||||
const result = buildRefNationTargetOptions({ actorNationId: 0, nations: joinNations, join });
|
||||
expect(result.nationTargets.che_임관?.map((entry) => [entry.value, entry.availableNow])).toEqual([
|
||||
[1, true],
|
||||
[2, false],
|
||||
[3, false],
|
||||
[4, false],
|
||||
[5, false],
|
||||
]);
|
||||
expect(result.nationTargets.che_임관?.map((entry) => entry.description?.split(' · ')[0])).toEqual([
|
||||
'현재 임관 가능',
|
||||
'임관이 금지되어 있습니다.',
|
||||
'유저장은 태수국에 임관할 수 없습니다.',
|
||||
'이민족 국가에 임관할 수 없습니다.',
|
||||
'임관이 제한되고 있습니다.',
|
||||
]);
|
||||
// 초반 제한이 끝나면 인원 제한은 없고, NPC(9)는 이민족 국가에 임관할 수 있다.
|
||||
const later = buildRefNationTargetOptions({
|
||||
actorNationId: 0,
|
||||
nations: joinNations,
|
||||
join: { ...join, actorNpcState: 9, relYear: 3 },
|
||||
});
|
||||
expect(later.nationTargets.che_임관?.map((entry) => entry.availableNow)).toEqual([
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(result.nationScoutMessages).toEqual(
|
||||
joinNations.map((entry) => ({
|
||||
nationId: entry.id,
|
||||
name: entry.name,
|
||||
color: entry.color,
|
||||
message: entry.scoutMessage ?? '',
|
||||
}))
|
||||
);
|
||||
// join 정보가 없으면 임관은 기존 공통 국가 목록을 그대로 쓴다.
|
||||
expect(buildRefNationTargetOptions({ actorNationId: 0, nations }).nationTargets.che_임관).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ref amount presets', () => {
|
||||
|
||||
@@ -384,8 +384,8 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
actorUserId: userId,
|
||||
});
|
||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||
const turnGridOffsetSeconds =
|
||||
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
|
||||
// 상속 턴 시간대는 daemon cursor가 아닌 5분 격자의 위상을 보존한다.
|
||||
const turnGridOffsetSeconds = ((created.turnTime.getTime() / 1000) % 300 + 300) % 300;
|
||||
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
||||
expect(turnGridOffsetSeconds).toBeLessThan(40);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
@@ -742,7 +743,7 @@ integration('diplomacy document message persistence', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('rolls back the document and notices if the audit insert fails', async () => {
|
||||
it('commits the document and notices with a gap when audit insert fails', async () => {
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||
@@ -754,15 +755,19 @@ integration('diplomacy document message persistence', () => {
|
||||
brief: '감사 실패',
|
||||
detail: '롤백',
|
||||
})
|
||||
).rejects.toThrow('injected audit insert failure');
|
||||
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0);
|
||||
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0);
|
||||
).resolves.toBeDefined();
|
||||
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(1);
|
||||
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBeGreaterThan(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
|
||||
expect(
|
||||
await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
|
||||
})
|
||||
).toMatchObject({ status: 'FAILED', attempts: 1 });
|
||||
).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
|
||||
expect(
|
||||
asRecord((await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldStateId } })).meta)
|
||||
.playAuditGap
|
||||
).toMatchObject({ serverId: requestPrefix });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
|
||||
|
||||
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
|
||||
|
||||
const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) return;
|
||||
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
|
||||
if (keys.length > 0) await redis.client.del(keys);
|
||||
}
|
||||
@@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', ()
|
||||
if (!created) throw new Error('failed to seed diplomacy HTML access token');
|
||||
accessToken = created.accessToken;
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||
}, 30_000);
|
||||
|
||||
@@ -59,6 +59,7 @@ const classifications = {
|
||||
'join.reselectPoolGeneral',
|
||||
'join.selectPoolGeneral',
|
||||
'nation.appoint',
|
||||
'nation.cityOfficeRequest',
|
||||
'nation.changePermission',
|
||||
'nation.kick',
|
||||
'nation.setBill',
|
||||
@@ -161,7 +162,6 @@ describe('game-api direct mutation journal inventory', () => {
|
||||
// count independently catches mutations that were added to a router but never mounted.
|
||||
expect(declaredCount).toBe(actual.length);
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
expect(classified).toHaveLength(88);
|
||||
expect(actual).toEqual(classified);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createServer, type Socket } from 'node:net';
|
||||
import { createClient } from 'redis';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||
import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||
|
||||
describe('RedisGatewayFlushSubscriber', () => {
|
||||
it('reports asynchronous flush handler failures with event context', async () => {
|
||||
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisFlushStore authorization state', () => {
|
||||
it('merges startup, persistent and local watermarks without caching an allow result', async () => {
|
||||
let persisted: string | null = null;
|
||||
const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted]));
|
||||
const store = new RedisFlushStore({ mGet }, 'fixture:flush');
|
||||
await store.assertInitialized();
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100);
|
||||
persisted = '200';
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200);
|
||||
store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() });
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300);
|
||||
expect(mGet).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it('retains revocation after local cache eviction and a fresh store instance', async () => {
|
||||
const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) };
|
||||
const store = new RedisFlushStore(client, 'fixture:flush');
|
||||
for (let i = 0; i < 4100; i++)
|
||||
store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() });
|
||||
expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||
expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||
});
|
||||
it.each([null, '', 'invalid', '-1', '8640000000000001'])(
|
||||
'rejects missing or corrupt initialization %s',
|
||||
async (baseline) => {
|
||||
const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush');
|
||||
await expect(store.assertInitialized()).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
it('rejects corrupted per-user state instead of treating it as never revoked', async () => {
|
||||
const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush');
|
||||
await expect(store.getFlushedAt('user-1')).rejects.toThrow();
|
||||
});
|
||||
it('does not perform an offline read and bounds a connected read with an abort signal', async () => {
|
||||
const offlineGet = vi.fn(async () => ['0', null]);
|
||||
const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush');
|
||||
await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable');
|
||||
expect(offlineGet).not.toHaveBeenCalled();
|
||||
const mGet = vi.fn(async () => ['0', '100']);
|
||||
const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet }));
|
||||
const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush');
|
||||
expect((await store.getFlushedAt('user'))?.getTime()).toBe(100);
|
||||
expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds a connected Redis read when the actual TCP peer stops replying', async () => {
|
||||
const sockets = new Set<Socket>();
|
||||
let receivedRead = false;
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
socket.on('data', (data) => {
|
||||
const request = data.toString();
|
||||
// Real node-redis handshake succeeds; its MGET command receives no reply.
|
||||
if (request.includes('MGET')) {
|
||||
receivedRead = true;
|
||||
return;
|
||||
}
|
||||
const count = [...request.matchAll(/\*\d+\r\n/g)].length;
|
||||
socket.write('+OK\r\n'.repeat(count));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address');
|
||||
const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } });
|
||||
try {
|
||||
await client.connect();
|
||||
expect(client.isReady).toBe(true);
|
||||
const start = Date.now();
|
||||
await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow();
|
||||
expect(receivedRead).toBe(true);
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(1800);
|
||||
expect(Date.now() - start).toBeLessThan(4500);
|
||||
} finally {
|
||||
if (client.isOpen) client.destroy();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
|
||||
db,
|
||||
generalAccessTracking: true,
|
||||
requestId: 'access-boundary-test',
|
||||
profile: { id: 'che:default', name: 'che' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
profileStatusSource,
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
|
||||
auth: token,
|
||||
db: { $executeRaw: executeRaw },
|
||||
generalAccessTracking: true,
|
||||
profile: { id: 'che:default', name: 'che', scenario: 'default' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
const activityRouter = router({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import fastify from 'fastify';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js';
|
||||
|
||||
const internalFailure = () => {
|
||||
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
|
||||
};
|
||||
const badInput = () => {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '잘못된 입력입니다.',
|
||||
cause: new Error('synthetic-private-cause'),
|
||||
});
|
||||
};
|
||||
|
||||
describe('public HTTP error privacy', () => {
|
||||
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
|
||||
const app = fastify();
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/game',
|
||||
trpcOptions: {
|
||||
router: gameRouter({
|
||||
internal: sessionActivityProcedure.query(internalFailure),
|
||||
invalid: sessionActivityProcedure.query(badInput),
|
||||
}),
|
||||
createContext: () => ({}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
for (const prefix of ['game']) {
|
||||
for (const procedure of ['internal', 'invalid']) {
|
||||
const response = await fetch(`${address}/${prefix}/${procedure}`);
|
||||
const body = (await response.json()) as {
|
||||
error: { message: string; data: { code: string; stack?: string } };
|
||||
};
|
||||
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
|
||||
expect(body.error.message).toBe(
|
||||
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
|
||||
);
|
||||
expect(body.error.data.stack).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('synthetic-');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,23 +21,35 @@ const auth: GameSessionTokenPayload = {
|
||||
};
|
||||
|
||||
const buildContext = (
|
||||
options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {}
|
||||
options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
hasVoted?: boolean;
|
||||
preopenClock?: boolean;
|
||||
officerLevel?: number;
|
||||
officeRows?: Array<{ id: number; userId: string; meta: Record<string, unknown> }>;
|
||||
} = {}
|
||||
) =>
|
||||
({
|
||||
auth: options.auth === undefined ? auth : options.auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 7,
|
||||
userId: 'owner',
|
||||
officerLevel: options.officerLevel ?? 1,
|
||||
nationId: 2,
|
||||
})),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 7, name: '유비', nationId: 2 },
|
||||
{ id: 8, name: '관우', nationId: 2 },
|
||||
{ id: 9, name: '조조', nationId: 3 },
|
||||
{ id: 10, name: '재야장수', nationId: 0 },
|
||||
]),
|
||||
findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
|
||||
input.where?.meta
|
||||
? (options.officeRows ?? [])
|
||||
: [
|
||||
{ id: 7, name: '유비', nationId: 2 },
|
||||
{ id: 8, name: '관우', nationId: 2 },
|
||||
{ id: 9, name: '조조', nationId: 3 },
|
||||
{ id: 10, name: '재야장수', nationId: 0 },
|
||||
]
|
||||
),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
@@ -109,6 +121,7 @@ describe('general.getFrontStatus', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: 'che_260819_front',
|
||||
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
|
||||
onlineUserCount: 4,
|
||||
onlineNations: '【촉】, 【위】',
|
||||
onlineGenerals: '유비, 관우',
|
||||
@@ -173,3 +186,31 @@ describe('general.getFrontStatus', () => {
|
||||
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
});
|
||||
|
||||
it('only notifies chiefs about pending requests belonging to current members and owners', async () => {
|
||||
const pending = {
|
||||
id: '8:760',
|
||||
generalId: 8,
|
||||
userId: 'user-8',
|
||||
nationId: 2,
|
||||
cityId: 1,
|
||||
officerLevel: 4,
|
||||
incumbentId: 0,
|
||||
quarter: 760,
|
||||
createdTick: 0,
|
||||
dueTick: 3600000,
|
||||
defaultDecision: 'approve',
|
||||
defaultReason: 'vacant',
|
||||
status: 'pending',
|
||||
};
|
||||
const officeRows = [
|
||||
{ id: 8, userId: 'user-8', meta: { cityOfficeRequest: pending } },
|
||||
{ id: 9, userId: 'changed-owner', meta: { cityOfficeRequest: { ...pending, id: '9:760' } } },
|
||||
{ id: 10, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '10:760', nationId: 3 } } },
|
||||
{ id: 11, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '11:760', status: 'rejected' } } },
|
||||
];
|
||||
const chief = appRouter.createCaller(buildContext({ officerLevel: 5, officeRows }));
|
||||
expect((await chief.general.getFrontStatus()).cityOfficeRequests.ids).toEqual(['8:760']);
|
||||
const member = appRouter.createCaller(buildContext({ officerLevel: 1, officeRows }));
|
||||
expect((await member.general.getFrontStatus()).cityOfficeRequests.ids).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ type LogQuery = {
|
||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||
({
|
||||
auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||
|
||||
@@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end
|
||||
|
||||
const expectedOwnerCounts: Record<string, number> = {
|
||||
'durable-journal': 19,
|
||||
'engine-owned': 38,
|
||||
'engine-owned': 39,
|
||||
'explicit-no-realtime-consumer': 7,
|
||||
'external-upload': 1,
|
||||
'mixed-saga': 9,
|
||||
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
|
||||
const rows = parseManifest();
|
||||
const manifestRoutes = rows.map(({ route }) => route);
|
||||
|
||||
expect(rows).toHaveLength(88);
|
||||
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
|
||||
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
|
||||
expect(manifestRoutes).toEqual(mountedMutationNames());
|
||||
|
||||
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||
for await (const keys of redis.client.scanIterator({
|
||||
MATCH: `sammo:game:*:${profileName}:*`,
|
||||
COUNT: 100,
|
||||
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
|
||||
}
|
||||
accessToken = created.accessToken;
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { once } from 'node:events';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChangeJournal } from '@sammo-ts/common';
|
||||
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
|
||||
expect(memberCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('city office requests', () => {
|
||||
it.each(['approve', 'reject', 'withdraw'] as const)(
|
||||
'binds %s to the session actor and durable request ID',
|
||||
async (action) => {
|
||||
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
|
||||
const caller = appRouter.createCaller(createContext({ requestId: 'office-http', requestCommand }));
|
||||
await expect(
|
||||
caller.nation.cityOfficeRequest({ action, targetGeneralId: 30, officeRequestId: '30:760' })
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'cityOfficeRequest',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
requestId: 'office-http:nation.cityOfficeRequest:engine:0:cityOfficeRequest',
|
||||
action,
|
||||
targetGeneralId: 30,
|
||||
officeRequestId: '30:760',
|
||||
});
|
||||
}
|
||||
);
|
||||
it('validates request fields and propagates engine rejection', async () => {
|
||||
const requestCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'cityOfficeRequest', ok: false, generalId: 22, reason: '이번 분기 자원 완료' });
|
||||
const caller = appRouter.createCaller(createContext({ requestCommand }));
|
||||
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 4 })).rejects.toMatchObject({
|
||||
message: '이번 분기 자원 완료',
|
||||
});
|
||||
// @ts-expect-error invalid officer must also be rejected at the HTTP boundary
|
||||
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 12 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(requestCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([1, 5])('redacts other requests for ordinary members (officer %s)', async (officerLevel) => {
|
||||
const office = {
|
||||
id: '30:760',
|
||||
generalId: 30,
|
||||
userId: 'user-30',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
officerLevel: 4,
|
||||
incumbentId: 0,
|
||||
quarter: 760,
|
||||
createdTick: 0,
|
||||
dueTick: 3600000,
|
||||
defaultDecision: 'approve',
|
||||
defaultReason: 'vacant',
|
||||
status: 'pending',
|
||||
};
|
||||
const me = { ...baseGeneral, officerLevel };
|
||||
const caller = appRouter.createCaller(
|
||||
createContext({
|
||||
me,
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findMany: vi.fn(async () => [
|
||||
me,
|
||||
{
|
||||
...baseGeneral,
|
||||
id: 30,
|
||||
userId: 'user-30',
|
||||
officerLevel: 1,
|
||||
meta: { belong: 5, cityOfficeRequest: office },
|
||||
},
|
||||
]),
|
||||
},
|
||||
nation: { findUnique: vi.fn(async () => ({ meta: { secretlimit: 3 } })) },
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '허창', nationId: 1, meta: {} }]) },
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
meta: {},
|
||||
config: { stat: { chiefMin: 65 } },
|
||||
clockBaseTime: new Date('2026-01-01'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-01-01'),
|
||||
clockPhase: 'RUNNING',
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const result = await caller.nation.getCityOfficeRequests();
|
||||
expect(result.requests).toHaveLength(officerLevel === 5 ? 1 : 0);
|
||||
expect(JSON.stringify(result)).not.toContain('user-30');
|
||||
expect(result.options.find((option) => option.level === 4)?.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('city office HTTP transport rejects unauthenticated and malformed requests and ignores a forged actor', async () => {
|
||||
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext: ({ req }): GameApiContext => ({
|
||||
...createContext({ requestId: 'office-http-wire', requestCommand }),
|
||||
auth: req.headers['x-test-session'] ? auth : null,
|
||||
}),
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address() as AddressInfo;
|
||||
const send = (body: unknown, authenticated = true) =>
|
||||
fetch(`http://127.0.0.1:${address.port}/nation.cityOfficeRequest`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(authenticated ? { 'x-test-session': 'fixture' } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
expect((await send({ action: 'request', officerLevel: 4 }, false)).status).toBe(401);
|
||||
expect((await send({ action: 'request', officerLevel: 12 })).status).toBe(400);
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
expect((await send({ action: 'request', officerLevel: 4, generalId: 99, userId: 'forged' })).status).toBe(200);
|
||||
expect(requestCommand).toHaveBeenCalledWith(expect.objectContaining({ generalId: 22, userId: 'user-22' }));
|
||||
requestCommand.mockResolvedValueOnce({
|
||||
type: 'cityOfficeRequest',
|
||||
ok: false,
|
||||
generalId: 22,
|
||||
reason: '이미 처리된 요청',
|
||||
});
|
||||
expect((await send({ action: 'approve', targetGeneralId: 30, officeRequestId: 'stale' })).status).toBe(400);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
summarizeNationPeriod,
|
||||
projectCurrentSettlement,
|
||||
zAuditNation,
|
||||
type AuditNationData,
|
||||
type NationMonthPoint,
|
||||
} from '../src/router/playAudit/nationSeries.js';
|
||||
|
||||
const population = {
|
||||
count: 0,
|
||||
crew: null,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 },
|
||||
@@ -89,3 +92,44 @@ describe('play audit half-year summary', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('current committed settlement', () => {
|
||||
it.each([
|
||||
[1, 'gold'],
|
||||
[7, 'rice'],
|
||||
] as const)('exposes month %s settlement before month-end', (month, resource) => {
|
||||
const meta = {
|
||||
playAuditFlows: {
|
||||
year: 200,
|
||||
month,
|
||||
complete: true,
|
||||
entries: { [`2:${resource}`]: { nationId: 2, resource, income: 1234.5, paid: 234 } },
|
||||
},
|
||||
};
|
||||
expect(projectCurrentSettlement(meta, 200, month, 2)[resource]).toEqual({ income: 1234.5, paid: 234 });
|
||||
expect(projectCurrentSettlement(meta, 200, month + 1, 2)[resource]).toBeNull();
|
||||
expect(projectCurrentSettlement(meta, 200, month, 3)[resource]).toBeNull();
|
||||
});
|
||||
it('keeps absent and malformed observations unknown, preserves actual zero', () => {
|
||||
expect(projectCurrentSettlement({}, 200, 1, 2).gold).toBeNull();
|
||||
const meta = {
|
||||
playAuditFlows: {
|
||||
year: 200,
|
||||
month: 1,
|
||||
complete: false,
|
||||
entries: { '2:gold': { nationId: 2, resource: 'gold', income: 0, paid: 0 } },
|
||||
},
|
||||
};
|
||||
expect(projectCurrentSettlement(meta, 200, 1, 2)).toMatchObject({
|
||||
gold: { income: 0, paid: 0 },
|
||||
complete: false,
|
||||
});
|
||||
meta.playAuditFlows.entries['2:gold'].income = NaN;
|
||||
expect(projectCurrentSettlement(meta, 200, 1, 2).gold).toBeNull();
|
||||
});
|
||||
it('reads old snapshots without inventing troop counts', () => {
|
||||
const old = JSON.parse(JSON.stringify(nation(1)));
|
||||
delete old.populations.human.crew;
|
||||
expect(zAuditNation.parse(old).populations.human.crew).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { loadProfileReadiness } from '../src/services/clockReadiness.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
integration('profile deployment readiness', () => {
|
||||
let db: GamePrismaClient;
|
||||
let close: () => Promise<void>;
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
close = () => connector.disconnect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await close?.();
|
||||
});
|
||||
it('rejects absent, initializing, expired and other-profile leases; accepts ready paused/preopen runtime', async () => {
|
||||
const rollback = new Error('readiness fixture rollback');
|
||||
await expect(
|
||||
db.$transaction(async (tx) => {
|
||||
// Shared integration fixtures may leave projection work; rollback restores it.
|
||||
await tx.clockProjectionOutbox.deleteMany();
|
||||
await tx.worldState.create({
|
||||
data: {
|
||||
id: -998901,
|
||||
scenarioCode: 'readiness-fixture',
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 300,
|
||||
clockPhase: 'PREOPEN',
|
||||
config: {},
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
const profile = 'readiness:fixture';
|
||||
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
|
||||
await tx.turnDaemonLease.create({
|
||||
data: {
|
||||
profile,
|
||||
ownerId: 'fixture',
|
||||
fencingEpoch: 1n,
|
||||
heartbeatAt: new Date(),
|
||||
leaseUntil: new Date(Date.now() + 60_000),
|
||||
clockReady: false,
|
||||
},
|
||||
});
|
||||
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
|
||||
await tx.turnDaemonLease.update({ where: { profile }, data: { clockReady: true } });
|
||||
expect((await loadProfileReadiness(tx, profile)).ok).toBe(true);
|
||||
expect((await loadProfileReadiness(tx, 'readiness:other')).ok).toBe(false);
|
||||
await tx.turnDaemonLease.update({
|
||||
where: { profile },
|
||||
data: { leaseUntil: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
|
||||
throw rollback;
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ const profile: GameProfile = {
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che',
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'ranking-session',
|
||||
@@ -239,7 +239,7 @@ const buildContext = (options?: {
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: selectedProfile,
|
||||
auth: options?.authenticated === false ? null : auth,
|
||||
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { RealtimeConnectionAdmission } from '../src/realtime/connectionAdmission.js';
|
||||
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('realtime connection admission', () => {
|
||||
it('reserves the process total before authentication and counts live streams too', () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 2, perUser: 2, opening: 2 });
|
||||
const first = budget.begin(vi.fn())!;
|
||||
const second = budget.begin(vi.fn())!;
|
||||
expect(first.reserveUser('actor')).toBe(true);
|
||||
expect(first.startStreaming()).toBe(true);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
second.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement).not.toBeNull();
|
||||
replacement.release();
|
||||
first.release();
|
||||
});
|
||||
|
||||
it('shares the authenticated actor limit while allowing another actor', () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 1, opening: 5 });
|
||||
const first = budget.begin(vi.fn())!;
|
||||
expect(first.reserveUser('one')).toBe(true);
|
||||
const rejected = budget.begin(vi.fn())!;
|
||||
expect(rejected.reserveUser('one')).toBe(false);
|
||||
expect(rejected.startStreaming()).toBe(false);
|
||||
rejected.release();
|
||||
const other = budget.begin(vi.fn())!;
|
||||
expect(other.reserveUser('two')).toBe(true);
|
||||
first.release();
|
||||
first.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('one')).toBe(true);
|
||||
other.release();
|
||||
replacement.release();
|
||||
});
|
||||
|
||||
it('keeps timed-out and disconnected backend work admitted until it actually settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 5, opening: 2 });
|
||||
const timeout = vi.fn();
|
||||
const stalled = budget.begin(timeout)!;
|
||||
const disconnected = budget.begin(vi.fn())!;
|
||||
expect(stalled.reserveUser('stalled')).toBe(true);
|
||||
disconnected.cancelOpening();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
expect(stalled.canContinue()).toBe(false);
|
||||
expect(stalled.startStreaming()).toBe(false);
|
||||
expect(disconnected.canContinue()).toBe(false);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
stalled.release();
|
||||
const recovered = budget.begin(vi.fn())!;
|
||||
expect(recovered.reserveUser('stalled')).toBe(true);
|
||||
expect(recovered.startStreaming()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
disconnected.release();
|
||||
recovered.release();
|
||||
});
|
||||
|
||||
it('stops pending setup on shutdown and permanently refuses new admissions', () => {
|
||||
const budget = new RealtimeConnectionAdmission();
|
||||
const timeout = vi.fn();
|
||||
const pending = budget.begin(timeout)!;
|
||||
budget.close();
|
||||
budget.close();
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
expect(pending.canContinue()).toBe(false);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
pending.release();
|
||||
});
|
||||
|
||||
it('keeps a closed stream counted while its in-flight authentication is still pending', async () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 1, perUser: 1, opening: 1 });
|
||||
const slot = budget.begin(vi.fn())!;
|
||||
expect(slot.reserveUser('actor')).toBe(true);
|
||||
expect(slot.startStreaming()).toBe(true);
|
||||
let finish!: () => void;
|
||||
let started!: () => void;
|
||||
const inFlight = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const running = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
sessionId: 'session',
|
||||
profile: 'che:default',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
user: {
|
||||
id: 'actor',
|
||||
username: 'actor',
|
||||
displayName: 'actor',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
const response = new PassThrough();
|
||||
const connection = new AuthenticatedRealtimeConnection(
|
||||
auth,
|
||||
response as unknown as ServerResponse,
|
||||
async () => {
|
||||
started();
|
||||
await inFlight;
|
||||
return auth;
|
||||
}
|
||||
);
|
||||
connection.onClose(() => {
|
||||
void connection.whenIdle().then(slot.release, slot.release);
|
||||
});
|
||||
connection.enqueue(async () => 'event: ready\ndata: {}\n\n');
|
||||
await running;
|
||||
connection.close();
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
finish();
|
||||
await connection.whenIdle();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('actor')).toBe(true);
|
||||
replacement.release();
|
||||
});
|
||||
|
||||
it('enforces the production 512 total and 32 pending openings without accumulating a queue', () => {
|
||||
const budget = new RealtimeConnectionAdmission();
|
||||
const pending = Array.from({ length: 32 }, () => budget.begin(vi.fn())!);
|
||||
expect(pending.every(Boolean)).toBe(true);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
for (const slot of pending) slot.release();
|
||||
const live = Array.from({ length: 512 }, (_, index) => {
|
||||
const slot = budget.begin(vi.fn())!;
|
||||
expect(slot.reserveUser(`actor-${index}`)).toBe(true);
|
||||
expect(slot.startStreaming()).toBe(true);
|
||||
return slot;
|
||||
});
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
live[0]!.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('replacement')).toBe(true);
|
||||
expect(replacement.startStreaming()).toBe(true);
|
||||
for (const slot of live) slot.release();
|
||||
replacement.release();
|
||||
});
|
||||
});
|
||||
@@ -36,4 +36,15 @@ describe('remote content image store', () => {
|
||||
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
|
||||
expect(Object.values(headers)).not.toContain(secret);
|
||||
});
|
||||
it('rejects oversized upload response JSON before trusting its path', async () => {
|
||||
const filename = `${'d'.repeat(32)}.png`;
|
||||
const store = new RemoteContentImageStore(
|
||||
'https://image.example',
|
||||
'https://image.example/public',
|
||||
'synthetic-secret',
|
||||
async () =>
|
||||
new Response(JSON.stringify({ path: `uploads/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
|
||||
);
|
||||
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Writable } from 'node:stream';
|
||||
import fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
|
||||
describe('HTTP log credential boundaries', () => {
|
||||
it('logs static routes/status but no query, unmatched path, header, body, stack or cause secrets', async () => {
|
||||
let output = '';
|
||||
const stream = new Writable({
|
||||
write(chunk, _encoding, done) {
|
||||
output += String(chunk);
|
||||
done();
|
||||
},
|
||||
});
|
||||
const app = fastify({ logger: { ...safeHttpLoggerOptions, stream } });
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
app.post('/events', async () => {
|
||||
throw Object.assign(new Error('synthetic-error-secret', { cause: new Error('synthetic-cause-secret') }), {
|
||||
code: 'P2002',
|
||||
detail: 'synthetic-detail-secret',
|
||||
});
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const response = await fetch(`${address}/events?token=synthetic-query-secret`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer synthetic-header-secret', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'synthetic-body-secret' }),
|
||||
});
|
||||
expect(await response.json()).toMatchObject({ message: 'Internal server error' });
|
||||
await fetch(`${address}/synthetic-path-secret`);
|
||||
const logs = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(logs.some((entry) => entry.req?.url === '/events')).toBe(true);
|
||||
expect(logs.some((entry) => entry.res?.statusCode === 500)).toBe(true);
|
||||
expect(logs.some((entry) => entry.err?.code === 'P2002')).toBe(true);
|
||||
for (const field of ['query', 'header', 'body', 'error', 'cause', 'detail', 'path']) {
|
||||
expect(output).not.toContain(`synthetic-${field}-secret`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import sharp from 'sharp';
|
||||
import { buildGameEventChannel } from '@sammo-ts/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
@@ -68,6 +70,7 @@ const envKeys = [
|
||||
'GATEWAY_REDIS_PREFIX',
|
||||
'GATEWAY_INTERNAL_API_URL',
|
||||
'GAME_UPLOAD_DIR',
|
||||
'API_ALLOWED_ORIGINS',
|
||||
] as const;
|
||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
@@ -172,7 +175,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||
for await (const keys of redis.client.scanIterator({
|
||||
MATCH: pattern,
|
||||
COUNT: 100,
|
||||
@@ -416,7 +419,7 @@ const readAccessTelemetryState = async () => ({
|
||||
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
||||
if (!redis) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||
for (const key of batch) keys.add(key);
|
||||
}
|
||||
@@ -640,6 +643,7 @@ integration('game API security over HTTP transport', () => {
|
||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
|
||||
|
||||
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||
await connector.connect();
|
||||
@@ -742,6 +746,8 @@ integration('game API security over HTTP transport', () => {
|
||||
await redis.connect();
|
||||
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({
|
||||
host: server.config.host,
|
||||
@@ -818,6 +824,28 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
|
||||
const input = { dataUrl: 'data:image/png;base64,AA==' };
|
||||
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
|
||||
expect(unauthenticated.response.status).toBe(401);
|
||||
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
|
||||
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
|
||||
expect(denied.response.status).toBe(403);
|
||||
const officer = await createAccessToken('image-officer', {});
|
||||
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
|
||||
expect(malformed.response.status).toBe(400);
|
||||
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const oversized = await requestTrpc('board.uploadImage', {
|
||||
method: 'POST',
|
||||
accessToken: officer,
|
||||
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
|
||||
});
|
||||
expect(oversized.response.status).toBe(400);
|
||||
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
|
||||
});
|
||||
|
||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||
const accessToken = await createAccessToken('json-query-body', {});
|
||||
const general = await requestTrpc('general.me', {
|
||||
@@ -2317,9 +2345,51 @@ integration('game API security over HTTP transport', () => {
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
ordinal: 0,
|
||||
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127
|
||||
? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} }
|
||||
: ({ ...step, sequence })),
|
||||
steps: Array.from({ length: 128 }, (_, sequence) =>
|
||||
sequence === 127
|
||||
? {
|
||||
...step,
|
||||
sequence,
|
||||
kind: 'DECISION_START',
|
||||
reservedAction: '휴식',
|
||||
effectivePolicy: {
|
||||
schemaVersion: 1,
|
||||
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
|
||||
nation: {
|
||||
priority: ['천도'],
|
||||
flags: { 천도: true },
|
||||
values: {
|
||||
reqNationGold: 4321,
|
||||
reqNationRice: 100,
|
||||
reqHumanWarUrgentGold: 100,
|
||||
reqHumanWarUrgentRice: 100,
|
||||
reqHumanWarRecommandGold: 100,
|
||||
reqHumanWarRecommandRice: 100,
|
||||
reqHumanDevelGold: 100,
|
||||
reqHumanDevelRice: 100,
|
||||
reqNpcWarGold: 100,
|
||||
reqNpcWarRice: 100,
|
||||
reqNpcDevelGold: 100,
|
||||
reqNpcDevelRice: 100,
|
||||
minimumResourceActionAmount: 100,
|
||||
maximumResourceActionAmount: 100,
|
||||
minNpcWarLeadership: 100,
|
||||
minWarCrew: 100,
|
||||
minNpcRecruitCityPopulation: 100,
|
||||
safeRecruitCityPopulationRatio: 100,
|
||||
properWarTrainAtmos: 100,
|
||||
cureThreshold: 100,
|
||||
},
|
||||
combatForce: { '1': [2, 3] },
|
||||
supportForce: [4],
|
||||
developForce: [5],
|
||||
secret: 'decision-secret',
|
||||
},
|
||||
secret: 'decision-secret',
|
||||
},
|
||||
}
|
||||
: { ...step, sequence }
|
||||
),
|
||||
},
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
@@ -2352,6 +2422,33 @@ integration('game API security over HTTP transport', () => {
|
||||
],
|
||||
});
|
||||
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
|
||||
// 월 경계 뒤 장수의 첫 턴 전에도 직전 결정에 도달할 수 있어야 한다.
|
||||
expect((await get('decisionHistory', admin, { generalId: decisionGeneral })).body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
selection: 'LATEST',
|
||||
month: { year: 190, month: 1 },
|
||||
currentMonth: { year: 190, month: 2 },
|
||||
items: expect.arrayContaining([expect.objectContaining({ id: decisionIds[0] })]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(await get('decisionHistory', admin, { generalId: decisionGeneral, month: { year: 190, month: 2 } }))
|
||||
.body
|
||||
).toMatchObject({
|
||||
result: { data: { selection: 'MONTH', month: { year: 190, month: 2 }, items: [] } },
|
||||
});
|
||||
expect(
|
||||
(await get('decisionHistory', admin, { generalId: decisionGeneral, phase: 'nation' })).body
|
||||
).toMatchObject({
|
||||
result: {
|
||||
data: { selection: 'LATEST', month: { year: 190, month: 1 }, items: [{ id: decisionIds[1] }] },
|
||||
},
|
||||
});
|
||||
expect((await get('decisionHistory', admin, { generalId: 2147483647 })).body).toMatchObject({
|
||||
result: { data: { selection: 'LATEST', month: { year: 190, month: 2 }, items: [] } },
|
||||
});
|
||||
expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401);
|
||||
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
|
||||
const decisionList = await get('decisionHistory', admin, decisionInput);
|
||||
@@ -2852,6 +2949,132 @@ integration('game API security over HTTP transport', () => {
|
||||
expect((await get('capabilities', admin)).body).toMatchObject({
|
||||
result: { data: { read: true, accounts: false } },
|
||||
});
|
||||
// 현재/과거 정렬은 전체 조건 안에서 수행하고 동점은 ID로 안정적으로 넘긴다.
|
||||
const originalSortRows = await db.general.findMany({
|
||||
where: { id: { in: [generalId, sameNationGeneralId] } },
|
||||
select: { id: true, gold: true },
|
||||
});
|
||||
await db.general.updateMany({
|
||||
where: { id: { in: [generalId, sameNationGeneralId] } },
|
||||
data: { gold: 54321 },
|
||||
});
|
||||
const sortedInput = { nationId: ownerNationId, sort: 'gold', order: 'desc', limit: 1 };
|
||||
expect((await get('generals', admin, sortedInput)).body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
items: [{ id: generalId, gold: 54321 }],
|
||||
nextCursor: { id: generalId, value: 54321 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(await get('generals', admin, { ...sortedInput, cursor: { id: generalId, value: 54321 } })).body
|
||||
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }] } } });
|
||||
for (const row of originalSortRows)
|
||||
await db.general.update({ where: { id: row.id }, data: { gold: row.gold } });
|
||||
for (const sort of [
|
||||
'gold',
|
||||
'rice',
|
||||
'crew',
|
||||
'train',
|
||||
'atmos',
|
||||
'leadership',
|
||||
'strength',
|
||||
'intelligence',
|
||||
'experience',
|
||||
'dedication',
|
||||
'dex1',
|
||||
'dex2',
|
||||
'dex3',
|
||||
'dex4',
|
||||
'dex5',
|
||||
]) {
|
||||
for (const at of [undefined, { year: 190, month: 1 }]) {
|
||||
expect((await get('generals', admin, { sort, at })).status).toBe(200);
|
||||
}
|
||||
}
|
||||
const oldGeneral = await db.playAuditGeneral.findUniqueOrThrow({
|
||||
where: { sampleId_generalId: { sampleId, generalId } },
|
||||
});
|
||||
await db.playAuditGeneral.create({
|
||||
data: {
|
||||
sampleId,
|
||||
generalId: sameNationGeneralId,
|
||||
nationId: oldGeneral.nationId,
|
||||
cityId: oldGeneral.cityId,
|
||||
npcState: oldGeneral.npcState,
|
||||
data: { ...asRecord(oldGeneral.data), id: sameNationGeneralId, name: '동점과거장수' },
|
||||
},
|
||||
});
|
||||
const historicalValue = past.gold;
|
||||
expect(
|
||||
(await get('generals', admin, { sort: 'gold', order: 'desc', at: { year: 190, month: 1 }, limit: 1 }))
|
||||
.body
|
||||
).toMatchObject({
|
||||
result: { data: { items: [{ id: generalId }], nextCursor: { id: generalId, value: historicalValue } } },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await get('generals', admin, {
|
||||
sort: 'gold',
|
||||
order: 'desc',
|
||||
at: { year: 190, month: 1 },
|
||||
limit: 1,
|
||||
cursor: { id: generalId, value: historicalValue },
|
||||
})
|
||||
).body
|
||||
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }], nextCursor: null } } });
|
||||
await db.playAuditGeneral.delete({
|
||||
where: { sampleId_generalId: { sampleId, generalId: sameNationGeneralId } },
|
||||
});
|
||||
expect((await get('generals', admin, { sort: 'gold', cursor: 1 })).status).toBe(400);
|
||||
expect((await get('generals', admin, { sort: 'gold; DROP TABLE general' })).status).toBe(400);
|
||||
expect(
|
||||
(await get('generals', admin, { sort: 'gold', at: { year: 190, month: 1 }, name: '%' })).body
|
||||
).toMatchObject({ result: { data: { items: [] } } });
|
||||
for (const accessToken of [
|
||||
undefined,
|
||||
await token(['admin']),
|
||||
await token(['admin.playAudit.read:other:default']),
|
||||
]) {
|
||||
expect((await get('cityMap', accessToken, {})).status).toBe(accessToken ? 403 : 401);
|
||||
expect((await get('generals', accessToken, { sort: 'crew' })).status).toBe(accessToken ? 403 : 401);
|
||||
}
|
||||
const citySource = await db.playAuditCity.findFirstOrThrow({ where: { sampleId }, select: { data: true } });
|
||||
await db.playAuditCity.create({
|
||||
data: {
|
||||
sampleId,
|
||||
cityId: 1,
|
||||
nationId: ownerNationId,
|
||||
data: { ...asRecord(citySource.data), id: 1, name: '지도과거도시' },
|
||||
},
|
||||
});
|
||||
const map = await get('cityMap', admin, { at: { year: 190, month: 1 } });
|
||||
expect(map.status).toBe(200);
|
||||
expect(map.body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
collected: true,
|
||||
map: {
|
||||
year: 190,
|
||||
month: 1,
|
||||
cityList: expect.arrayContaining([[1, 4, 0, ownerNationId, expect.any(Number), 1]]),
|
||||
},
|
||||
layout: {
|
||||
cityList: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 1, name: '지도과거도시' }),
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(map.body)).not.toContain('must-not-expose');
|
||||
await db.playAuditCity.delete({ where: { sampleId_cityId: { sampleId, cityId: 1 } } });
|
||||
expect((await get('cityMap', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
|
||||
result: { data: { collected: false, map: null } },
|
||||
});
|
||||
expect((await get('cityMap', admin, {})).status).toBe(200);
|
||||
expect((await get('cityMap', admin, { at: { year: 9999, month: 1 } })).status).toBe(400);
|
||||
const first = await get('generals', admin, { limit: 1 });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body).toMatchObject({
|
||||
@@ -3119,6 +3342,39 @@ integration('game API security over HTTP transport', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.playAuditNation.update({
|
||||
where: { sampleId_nationId: { sampleId: `${seasonId}:190:6`, nationId: ownerNationId } },
|
||||
data: {
|
||||
data: {
|
||||
...asRecord(finalNation.data),
|
||||
populations: {
|
||||
human: { ...population, count: 2 },
|
||||
npc: { ...population, count: 1 },
|
||||
troopNpc: population,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.playAuditGeneral.createMany({
|
||||
data: [1, 2].map((id) => ({
|
||||
sampleId: `${seasonId}:190:6`,
|
||||
generalId: id,
|
||||
nationId: ownerNationId,
|
||||
cityId: 0,
|
||||
npcState: 0,
|
||||
data: { crew: id * 1200 },
|
||||
})),
|
||||
});
|
||||
await db.playAuditGeneral.create({
|
||||
data: {
|
||||
sampleId: `${seasonId}:190:6`,
|
||||
generalId: 3,
|
||||
nationId: ownerNationId,
|
||||
cityId: 0,
|
||||
npcState: 2,
|
||||
data: { crew: 'invalid' },
|
||||
},
|
||||
});
|
||||
const series = await get('nationSeries', admin, {
|
||||
nationId: ownerNationId,
|
||||
from: { year: 190, month: 1 },
|
||||
@@ -3134,7 +3390,10 @@ integration('game API security over HTTP transport', () => {
|
||||
year: 190,
|
||||
month: 1,
|
||||
complete: true,
|
||||
stock: { gold: 600 },
|
||||
stock: {
|
||||
gold: 600,
|
||||
populations: { human: { crew: 3600 }, npc: { crew: null }, troopNpc: { crew: 0 } },
|
||||
},
|
||||
flows: { incomeGold: 21, incomeRice: 0 },
|
||||
},
|
||||
],
|
||||
@@ -3166,6 +3425,59 @@ integration('game API security over HTTP transport', () => {
|
||||
result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } },
|
||||
});
|
||||
|
||||
// 정산 commit 직후, 아직 월말 표본이 없는 상태를 HTTP로 확인한다.
|
||||
for (const [settlementMonth, resource] of [
|
||||
[1, 'gold'],
|
||||
[7, 'rice'],
|
||||
] as const) {
|
||||
await db.worldState.update({
|
||||
where: { id: fixtureWorldId },
|
||||
data: {
|
||||
currentYear: 191,
|
||||
currentMonth: settlementMonth,
|
||||
meta: {
|
||||
serverId: seasonId,
|
||||
scenarioMeta: { startYear: 190 },
|
||||
playAuditFlows: {
|
||||
year: 191,
|
||||
month: settlementMonth,
|
||||
complete: true,
|
||||
entries: {
|
||||
[`${ownerNationId}:${resource}`]: {
|
||||
nationId: ownerNationId,
|
||||
resource,
|
||||
income: 8765.5,
|
||||
paid: 4321,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const input = {
|
||||
nationId: ownerNationId,
|
||||
from: { year: 191, month: settlementMonth },
|
||||
to: { year: 191, month: settlementMonth },
|
||||
};
|
||||
expect((await get('nationSeries', admin, input)).body).toMatchObject({
|
||||
result: {
|
||||
data: {
|
||||
currentSettlement: {
|
||||
year: 191,
|
||||
month: settlementMonth,
|
||||
[resource]: { income: 8765.5, paid: 4321 },
|
||||
},
|
||||
items: [{ stock: null, complete: false }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((await get('nationSeries', undefined, input)).status).toBe(401);
|
||||
expect((await get('nationSeries', await token(['admin']), input)).status).toBe(403);
|
||||
expect(
|
||||
(await get('nationSeries', await token(['admin.playAudit.read:other:default']), input)).status
|
||||
).toBe(403);
|
||||
}
|
||||
|
||||
// A synchronized opening may start before the scenario's gameplay year.
|
||||
await db.worldState.update({
|
||||
where: { id: fixtureWorldId },
|
||||
@@ -3288,6 +3600,414 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const openRealtime = async (accessToken: string) => {
|
||||
const abort = new AbortController();
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
signal: abort.signal,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = response.body!.getReader();
|
||||
const ready = await reader.read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
return { abort, reader };
|
||||
};
|
||||
|
||||
it('SSE limits simultaneous openings for one authenticated user', async () => {
|
||||
const actor = `security-http-sse-concurrent-${process.pid}`;
|
||||
const token = await createAccessToken('sse-concurrent', {}, actor);
|
||||
const aborts = Array.from({ length: 10 }, () => new AbortController());
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
aborts.map((abort) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: abort.signal,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(responses.filter((response) => response.status === 200)).toHaveLength(8);
|
||||
expect(responses.filter((response) => response.status === 429)).toHaveLength(2);
|
||||
for (const response of responses.filter((item) => item.status === 429)) {
|
||||
expect(response.headers.get('retry-after')).toBe('15');
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
}
|
||||
} finally {
|
||||
for (const abort of aborts) abort.abort();
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE limits the same user across tokens and scopes and recovers after disconnect', async () => {
|
||||
const actor = `security-http-sse-shared-${process.pid}`;
|
||||
const token = await createAccessToken('sse-shared-a', {}, actor);
|
||||
const otherToken = await createAccessToken('sse-shared-b', {}, actor);
|
||||
const otherActorToken = await createAccessToken('sse-independent', {}, `${actor}-other`);
|
||||
const streams: Awaited<ReturnType<typeof openRealtime>>[] = [];
|
||||
const replacementAbort = new AbortController();
|
||||
try {
|
||||
for (let index = 0; index < 8; index += 1) streams.push(await openRealtime(token));
|
||||
const rejected = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
});
|
||||
expect(rejected.status).toBe(429);
|
||||
expect(await rejected.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
streams.push(await openRealtime(otherActorToken));
|
||||
await streams[0]!.reader.cancel();
|
||||
streams[0]!.abort.abort();
|
||||
let replacement: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
signal: replacementAbort.signal,
|
||||
});
|
||||
if (response.status === 200) replacement = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const ready = await replacement!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
} finally {
|
||||
replacementAbort.abort();
|
||||
for (const stream of streams) stream.abort.abort();
|
||||
await Promise.all([token, otherToken, otherActorToken].map((value) => accessTokenStore.revoke(value)));
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE bounds pending openings after disconnect and backend deadline until real SQL work settles', async () => {
|
||||
if (!/^[A-Za-z0-9_]+$/.test(profileId)) throw new Error('Invalid isolated schema identifier');
|
||||
const actor = `security-http-sse-pending-${process.pid}`;
|
||||
const tokens = await Promise.all(
|
||||
Array.from({ length: 33 }, (_, index) => createAccessToken(`sse-pending-${index}`, {}, `${actor}-${index}`))
|
||||
);
|
||||
const aborts = Array.from({ length: 33 }, () => new AbortController());
|
||||
let unlock!: () => void;
|
||||
const release = new Promise<void>((resolve) => {
|
||||
unlock = resolve;
|
||||
});
|
||||
let ready!: (tx: GamePrisma.TransactionClient) => void;
|
||||
let lockError!: (error: unknown) => void;
|
||||
const locked = new Promise<GamePrisma.TransactionClient>((resolve, reject) => {
|
||||
ready = resolve;
|
||||
lockError = reject;
|
||||
});
|
||||
// Only the guarded, dedicated fixture schema is locked. No runtime or Ref table is touched.
|
||||
const locking = db.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$executeRawUnsafe(`LOCK TABLE "${profileId}"."general" IN ACCESS EXCLUSIVE MODE`);
|
||||
ready(tx);
|
||||
await release;
|
||||
},
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
void locking.catch(lockError);
|
||||
try {
|
||||
const tx = await locked;
|
||||
const first = fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[0]!}` },
|
||||
signal: aborts[0]!.signal,
|
||||
}).then(
|
||||
() => false,
|
||||
() => true
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const rows = await tx.$queryRaw<Array<{ count: number }>>`
|
||||
SELECT COUNT(*)::int AS count FROM pg_locks
|
||||
WHERE NOT granted AND relation = ${`${profileId}.general`}::regclass
|
||||
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
`;
|
||||
return rows[0]!.count;
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const startedAt = Date.now();
|
||||
const waiting = tokens.slice(1, 32).map((token, index) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: aborts[index + 1]!.signal,
|
||||
})
|
||||
);
|
||||
aborts[0]!.abort();
|
||||
expect(await first).toBe(true);
|
||||
const responses = await Promise.all(waiting);
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4500);
|
||||
expect(Date.now() - startedAt).toBeLessThan(8500);
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_unavailable' });
|
||||
}
|
||||
const refusedAt = Date.now();
|
||||
const refused = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
expect(refused.status).toBe(503);
|
||||
expect(refused.headers.get('retry-after')).toBe('15');
|
||||
expect(Date.now() - refusedAt).toBeLessThan(2000);
|
||||
await refused.arrayBuffer();
|
||||
unlock();
|
||||
await locking;
|
||||
let recovered: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
if (response.status === 200) recovered = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const frame = await recovered!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(frame.value)).toContain('event: ready');
|
||||
} finally {
|
||||
unlock();
|
||||
for (const abort of aborts) abort.abort();
|
||||
await locking;
|
||||
await Promise.all(tokens.map((token) => accessTokenStore.revoke(token)));
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
|
||||
const token = await createAccessToken('origin-mutation', {});
|
||||
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
origin: 'https://untrusted.example',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect(await accessTokenStore.get(token)).not.toBeNull();
|
||||
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
|
||||
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization,content-type',
|
||||
},
|
||||
});
|
||||
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
|
||||
expect(preflight.headers.get('access-control-allow-origin')).toBe(
|
||||
origin === 'https://sam.hided.net' ? origin : null
|
||||
);
|
||||
}
|
||||
await accessTokenStore.revoke(token);
|
||||
});
|
||||
|
||||
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
|
||||
const foreign = await accessTokenStore.create({
|
||||
...buildPayload('trpc-foreign', {}),
|
||||
profile: 'other:default',
|
||||
});
|
||||
if (!foreign) throw new Error('Foreign token fixture failed');
|
||||
const before = await readReservedMutationState();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
|
||||
{
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await readReservedMutationState()).toEqual(before);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
|
||||
const token = await createAccessToken('sse-query', {});
|
||||
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
|
||||
expect(queryOnly.status).toBe(401);
|
||||
const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' });
|
||||
if (!foreign) throw new Error('SSE foreign token fixture failed');
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const stream = await openRealtime(token);
|
||||
stream.abort.abort();
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{ label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{
|
||||
label: 'profile game restriction',
|
||||
sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } },
|
||||
},
|
||||
])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => {
|
||||
const token = await createAccessToken(`sse-${label}`, sanctions);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes on idle expiry without forwarding another frame', async () => {
|
||||
const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() };
|
||||
const created = await accessTokenStore.create(auth);
|
||||
if (!created) throw new Error('SSE expiry token fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => {
|
||||
const accessToken = await createAccessToken('sse-revoke', {});
|
||||
const stream = await openRealtime(accessToken);
|
||||
const channel = server!.config.profileName;
|
||||
try {
|
||||
const event = JSON.stringify({ type: 'tournamentChanged' });
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
const valid = await stream.reader.read();
|
||||
expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant');
|
||||
expect(await accessTokenStore.revoke(accessToken)).toBe(true);
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => {
|
||||
const sseUser = `security-http-sse-flush-${process.pid}`;
|
||||
const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser));
|
||||
if (!created) throw new Error('SSE flush fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
await redis!.client.publish(
|
||||
`${redisPrefix}:flush`,
|
||||
JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
|
||||
const actor = `security-http-durable-missed-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missed', {}, actor);
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('retains persisted revocation state across an actual API restart', async () => {
|
||||
const actor = `security-http-durable-restart-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-restart', {}, actor);
|
||||
try {
|
||||
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await server!.app.close();
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
|
||||
method: 'POST',
|
||||
input: { gatewayToken },
|
||||
});
|
||||
expect(exchanged.response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
|
||||
const actor = `security-http-durable-sse-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-sse', {}, actor);
|
||||
const stream = await openRealtime(accessToken);
|
||||
try {
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await redis!.client.publish(
|
||||
buildGameEventChannel(profileName),
|
||||
JSON.stringify({ type: 'tournamentChanged' })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
|
||||
const actor = `security-http-durable-cutoff-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.set(key, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const fresh = await accessTokenStore.create({
|
||||
...buildPayload('fresh-cutoff', {}, actor),
|
||||
issuedAt: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
expect(fresh).not.toBeNull();
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
|
||||
200
|
||||
);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(fresh!.accessToken);
|
||||
}
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
|
||||
const actor = `security-http-durable-missing-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missing', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.del(key);
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
|
||||
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
expect(sse.status).toBe(503);
|
||||
await expect(createGameApiServer()).rejects.toThrow('startup refused');
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
// Flush invalidates every token issued before the user watermark. Keep it
|
||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||
// by the transport authorization matrix above.
|
||||
|
||||
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
|
||||
|
||||
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
||||
this.commands.push(command);
|
||||
if (command.type === 'adjustGeneralResources') {
|
||||
const adjustment = command.adjustments[0];
|
||||
if (!adjustment) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
if (command.type === 'tournamentAdjustGeneral') {
|
||||
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
|
||||
}
|
||||
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
||||
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
|
||||
if (nextGold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
|
||||
}
|
||||
this.gold.set(adjustment.generalId, nextGold);
|
||||
return {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
||||
totalRiceDelta: 0,
|
||||
};
|
||||
this.gold.set(command.generalId, nextGold);
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'setMySetting') {
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'adjustGeneralMeta') {
|
||||
if (this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
|
||||
}
|
||||
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
|
||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||
reason: 'tournamentJoin',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||
expect(transport.commands).toContainEqual({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
||||
generalId: general.id,
|
||||
goldDelta: -64,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||
reason: 'tournamentBet',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
||||
reason: 'tournamentBet',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
betGoldDelta: 600,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([
|
||||
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
|
||||
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
|
||||
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
|
||||
])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 800,
|
||||
myTotals: { 12: 800 },
|
||||
totalAmount: 1_800,
|
||||
});
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 1_000,
|
||||
myTotals: { 11: 200, 12: 800 },
|
||||
});
|
||||
expect(transport.gold.get(1)).toBe(2_800);
|
||||
});
|
||||
|
||||
it('does not infer a legacy bet actor from the current general owner', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(1, 3_000);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
expect(transport.gold.get(1)).toBe(3_000);
|
||||
});
|
||||
|
||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(snapshot.sourceRevision).toBe('41');
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 2_000);
|
||||
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(2_000);
|
||||
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
||||
|
||||
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
||||
|
||||
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
|
||||
sendCommand: async () => 'ok',
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) =>
|
||||
command.type === 'tournamentSeedNpcBets'
|
||||
? {
|
||||
type: command.type,
|
||||
ok: true,
|
||||
processedGeneralIds: command.bets.map((bet) => bet.generalId),
|
||||
skippedGeneralIds: [],
|
||||
}
|
||||
: null,
|
||||
requestStatus: async () => null,
|
||||
});
|
||||
|
||||
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
const daemonTransport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
|
||||
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'tournament:123:npc-bet:resources',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
|
||||
},
|
||||
{
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'tournament:123:npc-bet:meta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: 'tournament:123:npc-bet:atomic',
|
||||
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
|
||||
},
|
||||
]);
|
||||
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toHaveLength(1);
|
||||
// A fresh projection with the same seed and DB inputs has the same choices.
|
||||
await store.setBettingEntries([bets[0]!]);
|
||||
await seedNpcBets({
|
||||
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
});
|
||||
|
||||
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
|
||||
const state = createTournamentState({
|
||||
stage: 5,
|
||||
bettingId: 789,
|
||||
npcBettingPlan: [
|
||||
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
|
||||
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
|
||||
],
|
||||
});
|
||||
await store.setState(state);
|
||||
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
requestCommand: async (command) => {
|
||||
expect(command.type).toBe('tournamentSeedNpcBets');
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
|
||||
expect(opened.stage).toBe(6);
|
||||
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
|
||||
const state = createTournamentState({ stage: 5 });
|
||||
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses durable command identities after a partial enqueue failure', async () => {
|
||||
it('reuses durable command identities after an uncertain atomic command response', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
|
||||
const state = createTournamentState({ stage: 5, bettingId: 456 });
|
||||
await store.setState(state);
|
||||
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
|
||||
let fail = true;
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
if (command.type === 'adjustGeneralMeta' && fail) {
|
||||
if (command.type === 'tournamentSeedNpcBets' && fail) {
|
||||
fail = false;
|
||||
// The accepted debit may already have committed before retry.
|
||||
prisma.general.findMany = async () => [];
|
||||
throw new Error('enqueue unavailable');
|
||||
}
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
|
||||
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
);
|
||||
expect(await store.getState()).toMatchObject({
|
||||
stage: 5,
|
||||
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
|
||||
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
|
||||
});
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
|
||||
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
|
||||
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
|
||||
expect(commands.map((command) => command.requestId)).toEqual([
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
]);
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
},
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
requestStatus: async () => null,
|
||||
};
|
||||
|
||||
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
|
||||
expect(commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: expect.arrayContaining([
|
||||
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
|
||||
]),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,10 @@ const buildContext = (
|
||||
meta: options.worldMeta ?? { serverId: currentServerId },
|
||||
}),
|
||||
},
|
||||
gameHistory: {
|
||||
findUnique: async ({ where }: { where: { serverId: string } }) =>
|
||||
where.serverId === archiveServerId ? { map: 'miniche' } : null,
|
||||
},
|
||||
yearbookHistory: {
|
||||
findFirst: async (args: {
|
||||
where: { profileName: string; year?: number; month?: number };
|
||||
@@ -187,11 +191,13 @@ describe('historical yearbook access from dynasty', () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
firstYearMonth: 200 * 12,
|
||||
lastYearMonth: 200 * 12 + 1,
|
||||
currentYearMonth: 200 * 12 + 1,
|
||||
mapLayout: { mapName: 'miniche' },
|
||||
});
|
||||
expect(result.mapLayout.cityList.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns the same archived public history for distinct general owners', async () => {
|
||||
@@ -249,20 +255,22 @@ describe('historical yearbook access from dynasty', () => {
|
||||
|
||||
expect(canonical).toEqual(alias);
|
||||
expect(alias).toEqual(omitted);
|
||||
expect(canonical).toEqual({
|
||||
expect(canonical).toMatchObject({
|
||||
firstYearMonth: 219 * 12 + 11,
|
||||
lastYearMonth: 219 * 12 + 11,
|
||||
currentYearMonth: 220 * 12,
|
||||
mapLayout: { mapName: 'che' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads imported history under the short profile ID when world metadata has no server ID', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a'), { worldMeta: {} }));
|
||||
|
||||
await expect(caller.yearbook.getRange()).resolves.toEqual({
|
||||
await expect(caller.yearbook.getRange()).resolves.toMatchObject({
|
||||
firstYearMonth: 219 * 12 + 10,
|
||||
lastYearMonth: 219 * 12 + 10,
|
||||
currentYearMonth: 220 * 12,
|
||||
mapLayout: { mapName: 'che' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||
const world = await transaction.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true },
|
||||
select: {
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
clockTick: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
||||
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
||||
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
|
||||
OR (
|
||||
${suspendedTournamentBetCommand}
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
|
||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||
)
|
||||
OR (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
|
||||
/** A bounded, sticky notice: later successful writes cannot make history complete again. */
|
||||
export const markAuditGap = (world: InMemoryTurnWorld, stage: string): void => {
|
||||
const state = world.getState();
|
||||
const previous = asRecord(state.meta.playAuditGap);
|
||||
world.updateWorldMeta({
|
||||
playAuditGap: {
|
||||
serverId: state.meta.serverId ?? null,
|
||||
firstYear:
|
||||
previous.serverId === state.meta.serverId
|
||||
? (previous.firstYear ?? state.currentYear)
|
||||
: state.currentYear,
|
||||
firstMonth:
|
||||
previous.serverId === state.meta.serverId
|
||||
? (previous.firstMonth ?? state.currentMonth)
|
||||
: state.currentMonth,
|
||||
lastYear: state.currentYear,
|
||||
lastMonth: state.currentMonth,
|
||||
stage,
|
||||
},
|
||||
});
|
||||
console.warn(`[play-audit] ${stage}: history gap recorded; gameplay continues.`);
|
||||
};
|
||||
|
||||
export const collectPlayAudit = <T>(world: InMemoryTurnWorld, stage: string, collect: () => T, fallback: T): T => {
|
||||
try {
|
||||
return collect();
|
||||
} catch {
|
||||
markAuditGap(world, stage);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from './bestEffort.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js';
|
||||
import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js';
|
||||
@@ -32,86 +33,104 @@ const readFlows = (world: InMemoryTurnWorld): MonthlyFlows => {
|
||||
return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries };
|
||||
};
|
||||
|
||||
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void => {
|
||||
if (typeof world.getState().meta.serverId !== 'string') return;
|
||||
const flows = readFlows(world);
|
||||
const key = `${settlement.nationId}:${settlement.resource}`;
|
||||
const previous = flows.entries[key];
|
||||
flows.entries[key] = {
|
||||
...settlement,
|
||||
income: (previous?.income ?? 0) + settlement.income,
|
||||
paid: (previous?.paid ?? 0) + settlement.paid,
|
||||
};
|
||||
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
|
||||
world.updateWorldMeta({ playAuditFlows: flows });
|
||||
};
|
||||
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordAuditSettlement',
|
||||
() => {
|
||||
if (typeof world.getState().meta.serverId !== 'string') return;
|
||||
const flows = readFlows(world);
|
||||
const key = `${settlement.nationId}:${settlement.resource}`;
|
||||
const previous = flows.entries[key];
|
||||
flows.entries[key] = {
|
||||
...settlement,
|
||||
income: (previous?.income ?? 0) + settlement.income,
|
||||
paid: (previous?.paid ?? 0) + settlement.paid,
|
||||
};
|
||||
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
|
||||
world.updateWorldMeta({ playAuditFlows: flows });
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
export const queueAuditMonth = (
|
||||
world: InMemoryTurnWorld,
|
||||
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
|
||||
): void => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const flows = readFlows(world);
|
||||
const snapshot = buildAuditSnapshot({
|
||||
nations: world.listNations(),
|
||||
cities: world.listCities(),
|
||||
generals: world.listGenerals(),
|
||||
settlements: Object.values(flows.entries),
|
||||
settlementsComplete: flows.complete,
|
||||
});
|
||||
world.queueAuditMonth({
|
||||
...snapshot,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null),
|
||||
kind,
|
||||
settlementsComplete: flows.complete,
|
||||
});
|
||||
};
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'queueAuditMonth',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const flows = readFlows(world);
|
||||
const snapshot = buildAuditSnapshot({
|
||||
nations: world.listNations(),
|
||||
cities: world.listCities(),
|
||||
generals: world.listGenerals(),
|
||||
settlements: Object.values(flows.entries),
|
||||
settlementsComplete: flows.complete,
|
||||
});
|
||||
world.queueAuditMonth({
|
||||
...snapshot,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null),
|
||||
kind,
|
||||
settlementsComplete: flows.complete,
|
||||
});
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
|
||||
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
const previous = asRecord(state.meta.playAuditCollection);
|
||||
if (previous.serverId === serverId) {
|
||||
if (
|
||||
previous.schemaVersion !== 1 ||
|
||||
typeof previous.year !== 'number' ||
|
||||
!Number.isInteger(previous.year) ||
|
||||
previous.year < 0 ||
|
||||
typeof previous.month !== 'number' ||
|
||||
!Number.isInteger(previous.month) ||
|
||||
previous.month < 1 ||
|
||||
previous.month > 12 ||
|
||||
typeof previous.tick !== 'number' ||
|
||||
!Number.isSafeInteger(previous.tick) ||
|
||||
previous.tick < 0 ||
|
||||
typeof previous.observedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(previous.observedAt)) ||
|
||||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth
|
||||
)
|
||||
throw new Error('Invalid play audit collection boundary');
|
||||
return false;
|
||||
}
|
||||
queueAuditMonth(world, 'INITIAL');
|
||||
world.updateWorldMeta({
|
||||
playAuditCollection: {
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: world.getGameClockState().tick,
|
||||
observedAt: observedAt.toISOString(),
|
||||
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'initializeAuditCollection',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
const previous = asRecord(state.meta.playAuditCollection);
|
||||
if (previous.serverId === serverId) {
|
||||
if (
|
||||
previous.schemaVersion !== 1 ||
|
||||
typeof previous.year !== 'number' ||
|
||||
!Number.isInteger(previous.year) ||
|
||||
previous.year < 0 ||
|
||||
typeof previous.month !== 'number' ||
|
||||
!Number.isInteger(previous.month) ||
|
||||
previous.month < 1 ||
|
||||
previous.month > 12 ||
|
||||
typeof previous.tick !== 'number' ||
|
||||
!Number.isSafeInteger(previous.tick) ||
|
||||
previous.tick < 0 ||
|
||||
typeof previous.observedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(previous.observedAt)) ||
|
||||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth
|
||||
)
|
||||
throw new Error('Invalid play audit collection boundary');
|
||||
return false;
|
||||
}
|
||||
queueAuditMonth(world, 'INITIAL');
|
||||
world.updateWorldMeta({
|
||||
playAuditCollection: {
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: world.getGameClockState().tick,
|
||||
observedAt: observedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
false
|
||||
);
|
||||
|
||||
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
|
||||
beforeMonthChanged: (context) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from './bestEffort.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnDiplomacy } from '../turn/types.js';
|
||||
@@ -7,46 +8,52 @@ export const recordMonthlyAuditDiplomacy = (
|
||||
world: InMemoryTurnWorld,
|
||||
before: readonly TurnDiplomacy[],
|
||||
afterByKey: ReadonlyMap<string, TurnDiplomacy>
|
||||
): void => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const clock = world.getGameClockState();
|
||||
const project = (entry: TurnDiplomacy) => ({ state: entry.state, term: entry.term, dead: entry.dead });
|
||||
let ordinal = 0;
|
||||
for (const entry of [...before].sort(
|
||||
(left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
|
||||
)) {
|
||||
const next = afterByKey.get(`${entry.fromNationId}:${entry.toNationId}`);
|
||||
if (!next) continue;
|
||||
const previousState = project(entry);
|
||||
const nextState = project(next);
|
||||
if (JSON.stringify(previousState) === JSON.stringify(nextState)) continue;
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: 'MONTHLY_RELATION_CHANGED',
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: `monthly:${state.currentYear}:${state.currentMonth}:${clock.revision}`,
|
||||
ordinal: ++ordinal,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: previousState,
|
||||
after: nextState,
|
||||
});
|
||||
}
|
||||
};
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordMonthlyAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const clock = world.getGameClockState();
|
||||
const project = (entry: TurnDiplomacy) => ({ state: entry.state, term: entry.term, dead: entry.dead });
|
||||
let ordinal = 0;
|
||||
for (const entry of [...before].sort(
|
||||
(left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
|
||||
)) {
|
||||
const next = afterByKey.get(`${entry.fromNationId}:${entry.toNationId}`);
|
||||
if (!next) continue;
|
||||
const previousState = project(entry);
|
||||
const nextState = project(next);
|
||||
if (JSON.stringify(previousState) === JSON.stringify(nextState)) continue;
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: 'MONTHLY_RELATION_CHANGED',
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: `monthly:${state.currentYear}:${state.currentMonth}:${clock.revision}`,
|
||||
ordinal: ++ordinal,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: previousState,
|
||||
after: nextState,
|
||||
});
|
||||
}
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
export interface AuditDiplomacyAction {
|
||||
actionKey: string;
|
||||
@@ -68,116 +75,133 @@ export const recordTurnAuditDiplomacy = (
|
||||
after: TurnDiplomacy,
|
||||
action: AuditDiplomacyAction,
|
||||
turn: { generalId: number; tick: number; ordinal: number }
|
||||
): void => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const previousState = { state: before.state, term: before.term, dead: before.dead };
|
||||
const nextState = { state: after.state, term: after.term, dead: after.dead };
|
||||
if (JSON.stringify(previousState) === JSON.stringify(nextState)) return;
|
||||
const clock = world.getGameClockState();
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: before.fromNationId,
|
||||
destNationId: before.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: 'TURN_RELATION_CHANGED',
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(turn.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: `turn:${turn.generalId}:${turn.tick}:${clock.revision}`,
|
||||
ordinal: turn.ordinal,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: { ...action.actor, actionKey: action.actionKey, kind: action.kind, actionOrdinal: action.actionOrdinal },
|
||||
before: previousState,
|
||||
after: nextState,
|
||||
});
|
||||
};
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordTurnAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const previousState = { state: before.state, term: before.term, dead: before.dead };
|
||||
const nextState = { state: after.state, term: after.term, dead: after.dead };
|
||||
if (JSON.stringify(previousState) === JSON.stringify(nextState)) return;
|
||||
const clock = world.getGameClockState();
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: before.fromNationId,
|
||||
destNationId: before.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: 'TURN_RELATION_CHANGED',
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(turn.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: `turn:${turn.generalId}:${turn.tick}:${clock.revision}`,
|
||||
ordinal: turn.ordinal,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: {
|
||||
...action.actor,
|
||||
actionKey: action.actionKey,
|
||||
kind: action.kind,
|
||||
actionOrdinal: action.actionOrdinal,
|
||||
},
|
||||
before: previousState,
|
||||
after: nextState,
|
||||
});
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */
|
||||
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
const previous = asRecord(state.meta.playAuditDiplomacy);
|
||||
if (previous.serverId === serverId) {
|
||||
if (
|
||||
previous.schemaVersion !== 1 ||
|
||||
typeof previous.year !== 'number' ||
|
||||
previous.year < 0 ||
|
||||
!Number.isInteger(previous.year) ||
|
||||
!Number.isInteger(previous.month) ||
|
||||
typeof previous.month !== 'number' ||
|
||||
previous.month < 1 ||
|
||||
previous.month > 12 ||
|
||||
typeof previous.tick !== 'number' ||
|
||||
!Number.isSafeInteger(previous.tick) ||
|
||||
previous.tick < 0 ||
|
||||
typeof previous.clockRevision !== 'number' ||
|
||||
!Number.isSafeInteger(previous.clockRevision) ||
|
||||
previous.clockRevision < 0 ||
|
||||
typeof previous.relationCount !== 'number' ||
|
||||
!Number.isInteger(previous.relationCount) ||
|
||||
previous.relationCount < 0 ||
|
||||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth ||
|
||||
typeof previous.observedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(previous.observedAt))
|
||||
)
|
||||
throw new Error('Invalid play audit diplomacy boundary');
|
||||
return false;
|
||||
}
|
||||
const clock = world.getGameClockState();
|
||||
const observedAtIso = observedAt.toISOString();
|
||||
const relations = world
|
||||
.listDiplomacy()
|
||||
.filter((entry) => entry.fromNationId > 0 && entry.toNationId > 0)
|
||||
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
|
||||
for (const [index, entry] of relations.entries()) {
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'BASELINE',
|
||||
eventType: 'RELATION_BASELINE',
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: 'relation-baseline',
|
||||
ordinal: index + 1,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: null,
|
||||
after: { state: entry.state, term: entry.term, dead: entry.dead },
|
||||
});
|
||||
}
|
||||
world.updateWorldMeta({
|
||||
playAuditDiplomacy: {
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: clock.tick,
|
||||
clockRevision: clock.revision,
|
||||
observedAt: observedAtIso,
|
||||
relationCount: relations.length,
|
||||
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'initializeAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
const previous = asRecord(state.meta.playAuditDiplomacy);
|
||||
if (previous.serverId === serverId) {
|
||||
if (
|
||||
previous.schemaVersion !== 1 ||
|
||||
typeof previous.year !== 'number' ||
|
||||
previous.year < 0 ||
|
||||
!Number.isInteger(previous.year) ||
|
||||
!Number.isInteger(previous.month) ||
|
||||
typeof previous.month !== 'number' ||
|
||||
previous.month < 1 ||
|
||||
previous.month > 12 ||
|
||||
typeof previous.tick !== 'number' ||
|
||||
!Number.isSafeInteger(previous.tick) ||
|
||||
previous.tick < 0 ||
|
||||
typeof previous.clockRevision !== 'number' ||
|
||||
!Number.isSafeInteger(previous.clockRevision) ||
|
||||
previous.clockRevision < 0 ||
|
||||
typeof previous.relationCount !== 'number' ||
|
||||
!Number.isInteger(previous.relationCount) ||
|
||||
previous.relationCount < 0 ||
|
||||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth ||
|
||||
typeof previous.observedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(previous.observedAt))
|
||||
)
|
||||
throw new Error('Invalid play audit diplomacy boundary');
|
||||
return false;
|
||||
}
|
||||
const clock = world.getGameClockState();
|
||||
const observedAtIso = observedAt.toISOString();
|
||||
const relations = world
|
||||
.listDiplomacy()
|
||||
.filter((entry) => entry.fromNationId > 0 && entry.toNationId > 0)
|
||||
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
|
||||
for (const [index, entry] of relations.entries()) {
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'BASELINE',
|
||||
eventType: 'RELATION_BASELINE',
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId: 'relation-baseline',
|
||||
ordinal: index + 1,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: null,
|
||||
after: { state: entry.state, term: entry.term, dead: entry.dead },
|
||||
});
|
||||
}
|
||||
world.updateWorldMeta({
|
||||
playAuditDiplomacy: {
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: clock.tick,
|
||||
clockRevision: clock.revision,
|
||||
observedAt: observedAtIso,
|
||||
relationCount: relations.length,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
false
|
||||
);
|
||||
|
||||
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
|
||||
export const recordNationAuditDiplomacy = (
|
||||
@@ -186,47 +210,53 @@ export const recordNationAuditDiplomacy = (
|
||||
relations: readonly TurnDiplomacy[],
|
||||
operation: 'CREATED' | 'REMOVED',
|
||||
observedTick?: number
|
||||
): void => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const targets = new Set(nationIds.filter((id) => id > 0));
|
||||
const observed = relations
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.fromNationId > 0 &&
|
||||
entry.toNationId > 0 &&
|
||||
(targets.has(entry.fromNationId) || targets.has(entry.toNationId))
|
||||
)
|
||||
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
|
||||
if (!observed.length) return;
|
||||
// 전역 순번은 실행 identity에만 사용한다. 한 사건 묶음 안의 순서는 방향별 local ordinal이다.
|
||||
const executionId = `nation-relations:${world.nextAuditOrdinal()}`;
|
||||
const clock = world.getGameClockState();
|
||||
for (const [index, entry] of observed.entries()) {
|
||||
const value = { state: entry.state, term: entry.term, dead: entry.dead };
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: `NATION_RELATION_${operation}`,
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(observedTick ?? clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId,
|
||||
ordinal: index + 1,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: operation === 'REMOVED' ? value : null,
|
||||
after: operation === 'CREATED' ? value : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordNationAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const targets = new Set(nationIds.filter((id) => id > 0));
|
||||
const observed = relations
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.fromNationId > 0 &&
|
||||
entry.toNationId > 0 &&
|
||||
(targets.has(entry.fromNationId) || targets.has(entry.toNationId))
|
||||
)
|
||||
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
|
||||
if (!observed.length) return;
|
||||
// 전역 순번은 실행 identity에만 사용한다. 한 사건 묶음 안의 순서는 방향별 local ordinal이다.
|
||||
const executionId = `nation-relations:${world.nextAuditOrdinal()}`;
|
||||
const clock = world.getGameClockState();
|
||||
for (const [index, entry] of observed.entries()) {
|
||||
const value = { state: entry.state, term: entry.term, dead: entry.dead };
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: `NATION_RELATION_${operation}`,
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(observedTick ?? clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId,
|
||||
ordinal: index + 1,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: operation === 'REMOVED' ? value : null,
|
||||
after: operation === 'CREATED' ? value : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from './bestEffort.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { Nation } from '@sammo-ts/logic';
|
||||
@@ -70,85 +71,97 @@ export const recordAuditPolicyChange = (options: {
|
||||
actor?: TurnGeneral;
|
||||
permission?: number;
|
||||
requestId?: string;
|
||||
}): { _playAuditPolicy?: Record<string, PolicyHead> } => {
|
||||
const { world, nation, area } = options;
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return {};
|
||||
const rawHeads = asRecord(nation.meta._playAuditPolicy);
|
||||
const heads: Record<string, PolicyHead> = {};
|
||||
for (const key of AUDIT_POLICY_AREAS) {
|
||||
const value = asRecord(rawHeads[key]);
|
||||
if (
|
||||
value.serverId === serverId &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.revision === 'number' &&
|
||||
Number.isSafeInteger(value.revision) &&
|
||||
value.revision > 0 &&
|
||||
typeof value.hash === 'string' &&
|
||||
value.id === auditPolicyHash([serverId, nation.id, key, value.revision])
|
||||
) {
|
||||
heads[key] = { id: value.id, revision: value.revision, hash: value.hash, serverId };
|
||||
}
|
||||
}
|
||||
let head: PolicyHead | null = heads[area] ?? null;
|
||||
const before = projectAuditPolicy(nation.meta, area);
|
||||
const after = projectAuditPolicy(options.nextMeta, area);
|
||||
const append = (source: PendingAuditPolicy['source'], old: PolicyData | null, value: PolicyData) => {
|
||||
const revision = (head?.revision ?? 0) + 1;
|
||||
const id = auditPolicyHash([serverId, nation.id, area, revision]);
|
||||
const actor = options.actor;
|
||||
world.queueAuditPolicy({
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
serverId,
|
||||
nationId: nation.id,
|
||||
area,
|
||||
revision,
|
||||
previousId: head?.id ?? null,
|
||||
source,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: world.getGameClockState().tick,
|
||||
requestId: source === 'CHANGE' ? (options.requestId ?? null) : null,
|
||||
ordinal: world.nextAuditOrdinal(),
|
||||
actor:
|
||||
source === 'CHANGE' && actor
|
||||
? {
|
||||
userId: actor.userId ?? null,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission: options.permission ?? 0,
|
||||
}
|
||||
: null,
|
||||
before: old,
|
||||
after: value,
|
||||
});
|
||||
head = { id, revision, hash: auditPolicyHash(value), serverId };
|
||||
};
|
||||
if (!head) append('BASELINE', null, before);
|
||||
else if (head.hash !== auditPolicyHash(before)) append('OBSERVED_GAP', null, before);
|
||||
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
|
||||
if (!head) throw new Error('Play audit policy baseline missing');
|
||||
return { _playAuditPolicy: { ...heads, [area]: head } };
|
||||
};
|
||||
}): { _playAuditPolicy?: Record<string, PolicyHead> } =>
|
||||
collectPlayAudit(
|
||||
options.world,
|
||||
'recordAuditPolicyChange',
|
||||
() => {
|
||||
const { world, nation, area } = options;
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return {};
|
||||
const rawHeads = asRecord(nation.meta._playAuditPolicy);
|
||||
const heads: Record<string, PolicyHead> = {};
|
||||
for (const key of AUDIT_POLICY_AREAS) {
|
||||
const value = asRecord(rawHeads[key]);
|
||||
if (
|
||||
value.serverId === serverId &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.revision === 'number' &&
|
||||
Number.isSafeInteger(value.revision) &&
|
||||
value.revision > 0 &&
|
||||
typeof value.hash === 'string' &&
|
||||
value.id === auditPolicyHash([serverId, nation.id, key, value.revision])
|
||||
) {
|
||||
heads[key] = { id: value.id, revision: value.revision, hash: value.hash, serverId };
|
||||
}
|
||||
}
|
||||
let head: PolicyHead | null = heads[area] ?? null;
|
||||
const before = projectAuditPolicy(nation.meta, area);
|
||||
const after = projectAuditPolicy(options.nextMeta, area);
|
||||
const append = (source: PendingAuditPolicy['source'], old: PolicyData | null, value: PolicyData) => {
|
||||
const revision = (head?.revision ?? 0) + 1;
|
||||
const id = auditPolicyHash([serverId, nation.id, area, revision]);
|
||||
const actor = options.actor;
|
||||
world.queueAuditPolicy({
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
serverId,
|
||||
nationId: nation.id,
|
||||
area,
|
||||
revision,
|
||||
previousId: head?.id ?? null,
|
||||
source,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: world.getGameClockState().tick,
|
||||
requestId: source === 'CHANGE' ? (options.requestId ?? null) : null,
|
||||
ordinal: world.nextAuditOrdinal(),
|
||||
actor:
|
||||
source === 'CHANGE' && actor
|
||||
? {
|
||||
userId: actor.userId ?? null,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission: options.permission ?? 0,
|
||||
}
|
||||
: null,
|
||||
before: old,
|
||||
after: value,
|
||||
});
|
||||
head = { id, revision, hash: auditPolicyHash(value), serverId };
|
||||
};
|
||||
if (!head) append('BASELINE', null, before);
|
||||
else if (head.hash !== auditPolicyHash(before)) append('OBSERVED_GAP', null, before);
|
||||
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
|
||||
if (!head) throw new Error('Play audit policy baseline missing');
|
||||
return { _playAuditPolicy: { ...heads, [area]: head } };
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void => {
|
||||
let nation = world.getNationById(nationId);
|
||||
if (!nation) return;
|
||||
for (const area of AUDIT_POLICY_AREAS) {
|
||||
const patch = recordAuditPolicyChange({ world, nation, area, nextMeta: nation.meta });
|
||||
if (
|
||||
Object.keys(patch).length &&
|
||||
auditPolicyHash(patch._playAuditPolicy) !== auditPolicyHash(nation.meta._playAuditPolicy ?? {})
|
||||
) {
|
||||
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
|
||||
}
|
||||
}
|
||||
};
|
||||
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'initializeNationAuditPolicies',
|
||||
() => {
|
||||
let nation = world.getNationById(nationId);
|
||||
if (!nation) return;
|
||||
for (const area of AUDIT_POLICY_AREAS) {
|
||||
const patch = recordAuditPolicyChange({ world, nation, area, nextMeta: nation.meta });
|
||||
if (
|
||||
Object.keys(patch).length &&
|
||||
auditPolicyHash(patch._playAuditPolicy) !== auditPolicyHash(nation.meta._playAuditPolicy ?? {})
|
||||
) {
|
||||
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
|
||||
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
|
||||
import { auditPolicyHash, type PendingAuditPolicy } from './policy.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { AUDIT_POLICY_AREAS, auditPolicyHash, type PendingAuditPolicy } from './policy.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
|
||||
export const persistAuditPolicies = async (
|
||||
tx: GamePrisma.TransactionClient,
|
||||
@@ -43,3 +45,48 @@ export const persistAuditPolicies = async (
|
||||
throw new Error('Play audit policy replay payload conflict');
|
||||
}
|
||||
};
|
||||
|
||||
/** 원장은 그대로 두고, 구버전 NPC 생성이 유실한 head projection만 복구한다. */
|
||||
export const restoreMissingAuditPolicyHeads = async (
|
||||
tx: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<boolean> => {
|
||||
const serverId = world.getState().meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
const missing = world.listNations().flatMap((nation) => {
|
||||
const heads = asRecord(nation.meta._playAuditPolicy);
|
||||
return AUDIT_POLICY_AREAS.filter((area) => heads[area] === undefined).map(
|
||||
(area) => GamePrisma.sql`(nation_id = ${nation.id} AND area = ${area})`
|
||||
);
|
||||
});
|
||||
if (!missing.length) return false;
|
||||
// DISTINCT ON으로 각 국가/영역의 마지막 revision만 읽는다. 과거 기수는 복구하지 않는다.
|
||||
const rows = await tx.$queryRaw<
|
||||
{ id: string; nationId: number; area: string; revision: number; after: InputJsonValue }[]
|
||||
>(GamePrisma.sql`
|
||||
SELECT DISTINCT ON (nation_id, area)
|
||||
id, nation_id AS "nationId", area, revision, "after"
|
||||
FROM play_audit_policy
|
||||
WHERE server_id = ${serverId} AND (${GamePrisma.join(missing, ' OR ')})
|
||||
ORDER BY nation_id, area, revision DESC
|
||||
`);
|
||||
for (const row of rows) {
|
||||
if (row.revision < 1 || row.id !== auditPolicyHash([serverId, row.nationId, row.area, row.revision])) {
|
||||
throw new Error('Invalid persisted play audit policy head');
|
||||
}
|
||||
const nation = world.getNationById(row.nationId)!;
|
||||
const heads = nation.meta._playAuditPolicy;
|
||||
world.updateNation(nation.id, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
_playAuditPolicy: {
|
||||
...(heads && typeof heads === 'object' && !Array.isArray(heads) ? heads : {}),
|
||||
[row.area]: { id: row.id, revision: row.revision, hash: auditPolicyHash(row.after), serverId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// 실제 정책이 원장 after와 다르면 후속 initializeAuditPolicies가 OBSERVED_GAP을 남긴다.
|
||||
// 이 메타데이터와 새 이력은 기존 lease/fencing을 거친 startup flush에서 함께 commit된다.
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AuditPopulation = 'human' | 'npc' | 'troopNpc';
|
||||
|
||||
export interface AuditPopulationSummary {
|
||||
count: number;
|
||||
crew: number;
|
||||
gold: number;
|
||||
rice: number;
|
||||
dex: AuditDex;
|
||||
@@ -44,6 +45,7 @@ export interface AuditNationSnapshot {
|
||||
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
|
||||
const emptyPopulation = (): AuditPopulationSummary => ({
|
||||
count: 0,
|
||||
crew: 0,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
dex: emptyDex(),
|
||||
@@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: {
|
||||
const population = nations.get(general.nationId)?.populations[projected.population];
|
||||
if (!population) continue;
|
||||
population.count++;
|
||||
population.crew += projected.crew;
|
||||
population.gold += projected.gold;
|
||||
population.rice += projected.rice;
|
||||
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
asRecord,
|
||||
GAME_TICKS_PER_TURN,
|
||||
OFFICE_REQUEST_KEY,
|
||||
officeRequestQuarter,
|
||||
readOfficeRequest,
|
||||
type OfficeRequest,
|
||||
type TurnDaemonCommand,
|
||||
type TurnDaemonCommandResult,
|
||||
} from '@sammo-ts/common';
|
||||
import {
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
resolveOfficeRequestEligibility,
|
||||
type OfficeCandidate,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
const candidate = (general: TurnGeneral): OfficeCandidate => ({
|
||||
...general,
|
||||
strength: general.stats.strength,
|
||||
intelligence: general.stats.intelligence,
|
||||
});
|
||||
const incumbentFor = (world: InMemoryTurnWorld, cityId: number, level: number): TurnGeneral | undefined =>
|
||||
world
|
||||
.listGenerals()
|
||||
.find((g) => g.officerLevel === level && Number(g.meta.officerCity ?? g.meta.officer_city ?? 0) === cityId);
|
||||
const eligibility = (world: InMemoryTurnWorld, general: TurnGeneral, cityId: number, level: 2 | 3 | 4) => {
|
||||
const incumbent = incumbentFor(world, cityId, level);
|
||||
return resolveOfficeRequestEligibility({
|
||||
applicant: candidate(general),
|
||||
city: world.getCityById(cityId) ?? null,
|
||||
level,
|
||||
incumbent: incumbent ? candidate(incumbent) : null,
|
||||
nationMeta: world.getNationById(general.nationId)?.meta,
|
||||
chiefStatMin: world.getScenarioConfig().stat.chiefMin,
|
||||
});
|
||||
};
|
||||
const save = (world: InMemoryTurnWorld, generalId: number, request: OfficeRequest) => {
|
||||
const general = world.getGeneralById(generalId);
|
||||
if (general) world.updateGeneral(generalId, { meta: { ...general.meta, [OFFICE_REQUEST_KEY]: { ...request } } });
|
||||
};
|
||||
const finish = (world: InMemoryTurnWorld, request: OfficeRequest, status: OfficeRequest['status'], reason: string) => {
|
||||
save(world, request.generalId, { ...request, status, resultReason: reason });
|
||||
const general = world.getGeneralById(request.generalId);
|
||||
if (!general) return;
|
||||
const city = world.getCityById(request.cityId);
|
||||
const title = { 2: '종사', 3: '군사', 4: '태수' }[request.officerLevel];
|
||||
const text = `${general.name} · ${city?.name ?? '도시'} ${title} 자원: ${reason}`;
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: general.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
text,
|
||||
});
|
||||
world.pushLog({
|
||||
scope: LogScope.NATION,
|
||||
nationId: request.nationId,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.PLAIN,
|
||||
text,
|
||||
});
|
||||
};
|
||||
const invalidReason = (world: InMemoryTurnWorld, request: OfficeRequest): string | null => {
|
||||
const general = world.getGeneralById(request.generalId);
|
||||
if (!general || general.nationId !== request.nationId || general.userId !== request.userId)
|
||||
return '소속 또는 장수 소유자 변경';
|
||||
if ((incumbentFor(world, request.cityId, request.officerLevel)?.id ?? 0) !== request.incumbentId)
|
||||
return '재직자 변경';
|
||||
return eligibility(world, general, request.cityId, request.officerLevel).reason;
|
||||
};
|
||||
const approve = (world: InMemoryTurnWorld, request: OfficeRequest) => {
|
||||
const incumbent = incumbentFor(world, request.cityId, request.officerLevel);
|
||||
if (incumbent)
|
||||
world.updateGeneral(incumbent.id, {
|
||||
officerLevel: 1,
|
||||
meta: { ...incumbent.meta, officerCity: 0, officer_city: 0 },
|
||||
});
|
||||
const general = world.getGeneralById(request.generalId)!;
|
||||
world.updateGeneral(general.id, {
|
||||
officerLevel: request.officerLevel,
|
||||
meta: { ...general.meta, officerCity: request.cityId, officer_city: request.cityId },
|
||||
});
|
||||
const city = world.getCityById(request.cityId)!;
|
||||
world.updateCity(city.id, {
|
||||
meta: { ...city.meta, officer_set: Number(city.meta.officer_set ?? 0) | (1 << request.officerLevel) },
|
||||
});
|
||||
finish(world, request, 'approved', '임명');
|
||||
};
|
||||
|
||||
// 분기 lock 초기화 뒤 실행한다. 저장된 기본 거부는 조건이 완화되어도 자동 승인으로 바꾸지 않는다.
|
||||
export const settleCityOfficeRequests = (world: InMemoryTurnWorld, tick: number): void => {
|
||||
for (const general of world.listGenerals()) {
|
||||
const request = readOfficeRequest(general.meta);
|
||||
if (!request || request.status !== 'pending') continue;
|
||||
const invalid = invalidReason(world, request);
|
||||
if (invalid) {
|
||||
finish(world, request, 'cancelled', invalid);
|
||||
continue;
|
||||
}
|
||||
if (tick < request.dueTick) continue;
|
||||
const current = eligibility(world, general, request.cityId, request.officerLevel);
|
||||
if (request.defaultDecision === 'approve' && current.defaultDecision === 'approve') approve(world, request);
|
||||
else finish(world, request, 'rejected', '기한 만료 · 자동 거부');
|
||||
}
|
||||
};
|
||||
export const createCityOfficeRequestCalendarHandler = (
|
||||
getWorld: () => InMemoryTurnWorld | null
|
||||
): TurnCalendarHandler => ({
|
||||
beforeMonthChanged: () => {
|
||||
const world = getWorld();
|
||||
if (world) settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
|
||||
},
|
||||
onMonthChanged: (context) => {
|
||||
const world = getWorld();
|
||||
if (world) settleCityOfficeRequests(world, world.dateToGameTick(context.turnTime));
|
||||
},
|
||||
});
|
||||
|
||||
export const handleCityOfficeRequest = (
|
||||
world: InMemoryTurnWorld,
|
||||
command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
|
||||
acceptedAt: Date
|
||||
): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => {
|
||||
const fail = (reason: string): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => ({
|
||||
type: 'cityOfficeRequest',
|
||||
generalId: command.generalId,
|
||||
ok: false,
|
||||
reason,
|
||||
});
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor || actor.userId !== command.userId || !actor.nationId)
|
||||
return fail('장수 소유권 또는 소속이 변경되었습니다.');
|
||||
const tick = Math.max(world.dateToGameTick(world.getGameNow(acceptedAt)), world.getState().lastTurnTick ?? 0);
|
||||
settleCityOfficeRequests(world, tick);
|
||||
if (command.action === 'request') {
|
||||
if (!command.officerLevel) return fail('관직을 선택해 주세요.');
|
||||
const currentActor = world.getGeneralById(actor.id)!;
|
||||
const state = world.getState();
|
||||
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
|
||||
const previous = readOfficeRequest(currentActor.meta);
|
||||
if (previous?.status === 'pending') return fail('이미 대기 중인 요청이 있습니다.');
|
||||
if (previous?.quarter === quarter) return fail('이번 분기에 이미 자원했습니다.');
|
||||
const check = eligibility(world, currentActor, currentActor.cityId, command.officerLevel);
|
||||
if (!check.allowed) return fail(check.reason!);
|
||||
const occupied = world.listGenerals().some((g) => {
|
||||
const pending = readOfficeRequest(g.meta);
|
||||
return (
|
||||
pending?.status === 'pending' &&
|
||||
pending.cityId === actor.cityId &&
|
||||
pending.officerLevel === command.officerLevel
|
||||
);
|
||||
});
|
||||
if (occupied) return fail('이미 다른 장수가 자원한 관직입니다.');
|
||||
const boundary = state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime);
|
||||
const dueTick =
|
||||
boundary +
|
||||
Math.max(1, Math.ceil((tick - boundary + GAME_TICKS_PER_TURN) / GAME_TICKS_PER_TURN)) * GAME_TICKS_PER_TURN;
|
||||
save(world, actor.id, {
|
||||
id: `${actor.id}:${quarter}`,
|
||||
generalId: actor.id,
|
||||
userId: command.userId,
|
||||
nationId: actor.nationId,
|
||||
cityId: actor.cityId,
|
||||
officerLevel: command.officerLevel,
|
||||
incumbentId: incumbentFor(world, actor.cityId, command.officerLevel)?.id ?? 0,
|
||||
quarter,
|
||||
createdTick: tick,
|
||||
dueTick,
|
||||
defaultDecision: check.defaultDecision,
|
||||
defaultReason: check.defaultReason,
|
||||
status: 'pending',
|
||||
});
|
||||
} else {
|
||||
const target = world.getGeneralById(command.targetGeneralId ?? actor.id);
|
||||
const request = readOfficeRequest(target?.meta);
|
||||
if (!request || request.id !== command.officeRequestId || request.status !== 'pending')
|
||||
return fail('이미 처리되었거나 없는 요청입니다.');
|
||||
if (request.nationId !== actor.nationId) return fail('다른 국가의 요청입니다.');
|
||||
if (command.action === 'withdraw') {
|
||||
if (request.generalId !== actor.id) return fail('본인의 요청만 철회할 수 있습니다.');
|
||||
finish(world, request, 'withdrawn', '본인 철회');
|
||||
} else {
|
||||
if (actor.officerLevel < 5 || asRecord(actor.penalty).noChief) return fail('수뇌만 처리할 수 있습니다.');
|
||||
if (command.action === 'reject') finish(world, request, 'rejected', '수뇌 거부');
|
||||
else {
|
||||
if (asRecord(actor.penalty).noChiefChange && target && target.officerLevel >= 4)
|
||||
return fail('수뇌인 장수를 변경할 수 없는 상태입니다.');
|
||||
const invalid = invalidReason(world, request);
|
||||
if (invalid) return fail(invalid);
|
||||
approve(world, request);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'cityOfficeRequest', generalId: actor.id, ok: true };
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
GAME_TICKS_PER_TURN,
|
||||
readOfficeRequest,
|
||||
MAX_SAFE_GAME_TICK,
|
||||
GameClock,
|
||||
buildClockAlignmentPlan,
|
||||
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
|
||||
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'city-office-request-occurrence',
|
||||
'KEEP',
|
||||
generals.flatMap(({ id, meta }) => {
|
||||
const request = readOfficeRequest(meta);
|
||||
return request ? [{ id, createdTick: request.createdTick }] : [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'city-office-request-deadline',
|
||||
'SHIFT',
|
||||
generals.flatMap(({ id, meta }) => {
|
||||
const request = readOfficeRequest(meta);
|
||||
return request?.status === 'pending' && BigInt(request.dueTick) >= cutTick
|
||||
? [{ id, dueTick: request.dueTick }]
|
||||
: [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'auction-open-occurrence',
|
||||
'KEEP',
|
||||
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
|
||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||
const shift = BigInt(shiftTicks);
|
||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore, office] = await Promise.all([
|
||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
||||
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
|
||||
_max: { pickMoreFromTick: true },
|
||||
where: { pickMoreFromTick: { not: null } },
|
||||
}),
|
||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||
SELECT MAX((meta #>> '{cityOfficeRequest,dueTick}')::bigint) AS "maxTick"
|
||||
FROM general WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||
`),
|
||||
]);
|
||||
const values: Array<[string, bigint | null]> = [
|
||||
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
|
||||
['general.turn_tick', general._max.turnTick],
|
||||
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
||||
['auction.close_tick', auction._max.closeTick],
|
||||
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
|
||||
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'city-office-request-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE general SET meta = jsonb_set(meta, '{cityOfficeRequest,dueTick}',
|
||||
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${shiftTicks}))
|
||||
WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||
AND (meta #>> '{cityOfficeRequest,dueTick}')::bigint >= ${cutTick}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'auction-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
|
||||
@@ -185,6 +185,16 @@ const zKick = z.object({
|
||||
destGeneralId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zCityOfficeRequest = z.object({
|
||||
type: z.literal('cityOfficeRequest'),
|
||||
userId: z.string().min(1),
|
||||
generalId: z.number().int().positive(),
|
||||
action: z.enum(['request', 'approve', 'reject', 'withdraw']),
|
||||
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
|
||||
targetGeneralId: z.number().int().positive().optional(),
|
||||
officeRequestId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const zAppoint = z.object({
|
||||
type: z.literal('appoint'),
|
||||
userId: z.string().min(1),
|
||||
@@ -259,6 +269,21 @@ const zSetNpcPolicy = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
const zTournamentAdjustGeneral = z.object({
|
||||
type: z.literal('tournamentAdjustGeneral'),
|
||||
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
|
||||
userId: z.string().min(1),
|
||||
generalId: zSafeInteger.positive(),
|
||||
goldDelta: zFiniteNumber,
|
||||
betGoldDelta: zFiniteNumber.optional(),
|
||||
minGoldAfter: zFiniteNumber.nonnegative(),
|
||||
});
|
||||
|
||||
const zTournamentSeedNpcBets = z.object({
|
||||
type: z.literal('tournamentSeedNpcBets'),
|
||||
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
|
||||
});
|
||||
|
||||
const zAdjustGeneralResources = z.object({
|
||||
type: z.literal('adjustGeneralResources'),
|
||||
reason: z.string().optional(),
|
||||
@@ -666,6 +691,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeCityOfficeRequest: CommandNormalizer<'cityOfficeRequest'> = (envelope) => {
|
||||
const command = parseWith(zCityOfficeRequest, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => {
|
||||
const command = parseWith(zAppoint, envelope.command);
|
||||
if (!command) {
|
||||
@@ -722,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
|
||||
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
|
||||
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
|
||||
const command = parseWith(zAdjustGeneralResources, envelope.command);
|
||||
if (!command) {
|
||||
@@ -883,12 +923,15 @@ const normalizers: CommandNormalizerMap = {
|
||||
changePermission: normalizeChangePermission,
|
||||
kick: normalizeKick,
|
||||
appoint: normalizeAppoint,
|
||||
cityOfficeRequest: normalizeCityOfficeRequest,
|
||||
tournamentRefund: normalizeTournamentRefund,
|
||||
tournamentBettingPayout: normalizeTournamentBettingPayout,
|
||||
tournamentReward: normalizeTournamentReward,
|
||||
voteReward: normalizeVoteReward,
|
||||
setNationSetting: normalizeSetNationSetting,
|
||||
setNpcPolicy: normalizeSetNpcPolicy,
|
||||
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
|
||||
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
|
||||
adjustGeneralResources: normalizeAdjustGeneralResources,
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
|
||||
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
|
||||
import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js';
|
||||
import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra';
|
||||
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
|
||||
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
|
||||
import { persistAuditPolicies, restoreMissingAuditPolicyHeads } from '../playAudit/policyPersistence.js';
|
||||
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
|
||||
import { persistAuditMonth } from '../playAudit/persistence.js';
|
||||
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
|
||||
@@ -78,6 +79,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
flushChanges(): Promise<void>;
|
||||
restoreMissingAuditPolicyHeads(): Promise<boolean>;
|
||||
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
|
||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||
@@ -206,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
|
||||
permission: asRecord(general.meta).permission,
|
||||
penalty: general.penalty,
|
||||
}),
|
||||
frontStatus: signature({ name: general.name, nationId: general.nationId }),
|
||||
frontStatus: signature({
|
||||
name: general.name,
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
userId: general.userId,
|
||||
penalty: general.penalty,
|
||||
cityOfficeRequest: asRecord(general.meta).cityOfficeRequest,
|
||||
}),
|
||||
frontStatusGlobal: '',
|
||||
lobbyCount: signature({ npcState: general.npcState }),
|
||||
lobbyPersonal: signature({
|
||||
@@ -1235,6 +1244,7 @@ export const createDatabaseTurnHooks = async (
|
||||
}
|
||||
const persistedClock = await prisma.$queryRaw<
|
||||
Array<{
|
||||
audit_gap: unknown;
|
||||
clock_phase: string;
|
||||
clock_revision: bigint;
|
||||
deadline_generation: bigint;
|
||||
@@ -1242,7 +1252,7 @@ export const createDatabaseTurnHooks = async (
|
||||
opening_reached: boolean;
|
||||
}>
|
||||
>(GamePrisma.sql`
|
||||
SELECT clock_phase,
|
||||
SELECT meta->'playAuditGap' AS audit_gap, clock_phase,
|
||||
clock_revision,
|
||||
deadline_generation,
|
||||
clock_base_time IS NOT NULL
|
||||
@@ -1258,6 +1268,21 @@ export const createDatabaseTurnHooks = async (
|
||||
if (!durableClock) {
|
||||
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
|
||||
}
|
||||
const durableGap = asRecord(durableClock.audit_gap);
|
||||
if (durableGap.serverId && durableGap.serverId === state.meta.serverId) {
|
||||
const localGap = asRecord(world.getState().meta.playAuditGap);
|
||||
const gap =
|
||||
localGap.serverId === durableGap.serverId
|
||||
? {
|
||||
...durableGap,
|
||||
...localGap,
|
||||
firstYear: durableGap.firstYear,
|
||||
firstMonth: durableGap.firstMonth,
|
||||
}
|
||||
: durableGap;
|
||||
world.updateWorldMeta({ playAuditGap: gap });
|
||||
worldStateUpdate.meta = asJson(world.getState().meta);
|
||||
}
|
||||
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
|
||||
const expectedRevision = BigInt(state.clockRevision ?? 1);
|
||||
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
|
||||
@@ -1388,7 +1413,12 @@ export const createDatabaseTurnHooks = async (
|
||||
GamePrisma.sql`
|
||||
UPDATE general
|
||||
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END,
|
||||
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second')
|
||||
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second'),
|
||||
meta = CASE WHEN meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||
THEN jsonb_set(meta, '{cityOfficeRequest,dueTick}',
|
||||
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${deltaTicks}))
|
||||
ELSE meta END
|
||||
`
|
||||
);
|
||||
await prisma.$executeRaw(
|
||||
@@ -1894,11 +1924,25 @@ export const createDatabaseTurnHooks = async (
|
||||
data: pendingLogRows,
|
||||
});
|
||||
}
|
||||
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
|
||||
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
|
||||
await persistAuditDecisions(prisma, pendingAuditDecisions);
|
||||
for (const snapshot of pendingAuditMonths) {
|
||||
await persistAuditMonth(prisma, snapshot);
|
||||
if (
|
||||
pendingAuditPolicies.length ||
|
||||
pendingAuditDiplomacy.length ||
|
||||
pendingAuditDecisions.length ||
|
||||
pendingAuditMonths.length
|
||||
) {
|
||||
const audit = await withPlayAuditSavepoint(prisma, async (auditDb) => {
|
||||
await persistAuditPolicies(auditDb, pendingAuditPolicies, auditCommand);
|
||||
await persistAuditDiplomacyEvents(auditDb, pendingAuditDiplomacy);
|
||||
await persistAuditDecisions(auditDb, pendingAuditDecisions);
|
||||
for (const snapshot of pendingAuditMonths) await persistAuditMonth(auditDb, snapshot);
|
||||
});
|
||||
if (!audit.ok) {
|
||||
markAuditGap(world, 'persistence');
|
||||
await prisma.worldState.update({
|
||||
where: { id: state.id },
|
||||
data: { meta: world.getState().meta as GamePrisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const snapshot of pendingYearbookSnapshots) {
|
||||
await persistYearbookSnapshot(prisma, snapshot);
|
||||
@@ -2093,8 +2137,8 @@ export const createDatabaseTurnHooks = async (
|
||||
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
|
||||
};
|
||||
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
|
||||
if (hasAuditDocumentBaseline(world)) {
|
||||
if (force || world.hasPendingAuditRecords()) await flushChanges();
|
||||
if (collectPlayAudit(world, 'document-boundary', () => hasAuditDocumentBaseline(world), false)) {
|
||||
if (force || world.hasPendingAuditRecords() || world.getState().meta.playAuditGap) await flushChanges();
|
||||
return;
|
||||
}
|
||||
const checkpoint = world.captureState();
|
||||
@@ -2107,7 +2151,10 @@ export const createDatabaseTurnHooks = async (
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||
await persistAuditDocumentBaseline(transaction, world, observedAt);
|
||||
const audit = await withPlayAuditSavepoint(transaction, (auditDb) =>
|
||||
persistAuditDocumentBaseline(auditDb, world, observedAt)
|
||||
);
|
||||
if (!audit.ok) markAuditGap(world, 'document-baseline');
|
||||
return persistChanges(transaction);
|
||||
}, transactionOptions);
|
||||
} catch (error) {
|
||||
@@ -2164,6 +2211,17 @@ export const createDatabaseTurnHooks = async (
|
||||
hooks,
|
||||
flushChanges,
|
||||
flushInitialAudit,
|
||||
restoreMissingAuditPolicyHeads: async () => {
|
||||
const result = await prisma.$transaction(
|
||||
(tx) => withPlayAuditSavepoint(tx, (auditDb) => restoreMissingAuditPolicyHeads(auditDb, world)),
|
||||
transactionOptions
|
||||
);
|
||||
if (!result.ok) {
|
||||
markAuditGap(world, 'policy-head-recovery');
|
||||
return false;
|
||||
}
|
||||
return result.value;
|
||||
},
|
||||
takeCommittedReadModelChanges: () => {
|
||||
return takeCommittedReceipt()?.changes ?? null;
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from '../playAudit/bestEffort.js';
|
||||
import type { PendingAuditDecision } from '../playAudit/decision.js';
|
||||
import {
|
||||
recordTurnAuditDiplomacy,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
import {
|
||||
GAME_TICKS_PER_TURN,
|
||||
shiftOfficeRequestDeadline,
|
||||
GameClock,
|
||||
assertGameplayCommitAllowed,
|
||||
inferClockPhase,
|
||||
@@ -818,6 +820,7 @@ export class InMemoryTurnWorld {
|
||||
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
|
||||
this.generals.set(generalId, {
|
||||
...general,
|
||||
meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick),
|
||||
turnTick,
|
||||
turnTime: clock.tickToDate(turnTick),
|
||||
});
|
||||
@@ -1043,6 +1046,7 @@ export class InMemoryTurnWorld {
|
||||
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
|
||||
this.generals.set(generalId, {
|
||||
...general,
|
||||
meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks),
|
||||
turnTick,
|
||||
turnTime: clock.tickToDate(turnTick),
|
||||
});
|
||||
@@ -1393,15 +1397,36 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
|
||||
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
|
||||
this.pendingAuditDiplomacy.push(structuredClone(event));
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queueDiplomacy',
|
||||
() => {
|
||||
this.pendingAuditDiplomacy.push(structuredClone(event));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
queueAuditDecision(decision: PendingAuditDecision): void {
|
||||
this.pendingAuditDecisions.push(structuredClone(decision));
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queueDecision',
|
||||
() => {
|
||||
this.pendingAuditDecisions.push(structuredClone(decision));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
queueAuditPolicy(policy: PendingAuditPolicy): void {
|
||||
this.pendingAuditPolicies.push(structuredClone(policy));
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queuePolicy',
|
||||
() => {
|
||||
this.pendingAuditPolicies.push(structuredClone(policy));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
hasPendingAuditRecords(): boolean {
|
||||
@@ -1414,7 +1439,14 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
|
||||
queueAuditMonth(snapshot: PendingAuditMonth): void {
|
||||
this.pendingAuditMonths.push(structuredClone(snapshot));
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queueMonth',
|
||||
() => {
|
||||
this.pendingAuditMonths.push(structuredClone(snapshot));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
|
||||
|
||||
@@ -396,7 +396,8 @@ export const createRaiseInvaderHandler = (options: {
|
||||
}
|
||||
world.updateNation(nationId, {
|
||||
chiefGeneralId: ruler.id,
|
||||
meta: { ...nation.meta, gennum: npcEachCount },
|
||||
// addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
|
||||
meta: { ...world.getNationById(nationId)!.meta, gennum: npcEachCount },
|
||||
});
|
||||
for (const officerLevel of [12, 11, 10, 9]) {
|
||||
options.reservedTurns.ensureNationTurns(nationId, officerLevel);
|
||||
|
||||
@@ -395,7 +395,8 @@ export const createRaiseNpcNationHandler = (options: {
|
||||
}
|
||||
world.updateNation(nationId, {
|
||||
chiefGeneralId: ruler.id,
|
||||
meta: { ...nation.meta, gennum: 1 + subordinateCandidates.length },
|
||||
// addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
|
||||
meta: { ...world.getNationById(nationId)!.meta, gennum: 1 + subordinateCandidates.length },
|
||||
});
|
||||
options.reservedTurns.ensureNationTurns(nationId, 12);
|
||||
options.reservedTurns.ensureNationTurns(nationId, 11);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from '../playAudit/bestEffort.js';
|
||||
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
|
||||
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
|
||||
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
|
||||
@@ -1085,46 +1086,61 @@ export const createReservedTurnHandler = async (options: {
|
||||
const onDecisionTrace: AiDecisionTraceObserver | undefined =
|
||||
collectDecisions || options.onDecisionTrace
|
||||
? (event) => {
|
||||
if (collectDecisions) {
|
||||
if (event.kind === 'DECISION_START') {
|
||||
decisionSteps.set(event.phase, []);
|
||||
const heads = asRecord(currentNation?.meta._playAuditPolicy);
|
||||
decisionPolicyRefs.set(
|
||||
event.phase,
|
||||
Object.fromEntries(
|
||||
AUDIT_POLICY_AREAS.flatMap((area) => {
|
||||
const head = asRecord(heads[area]);
|
||||
return head.serverId === auditServerId && typeof head.id === 'string'
|
||||
? [[area, head.id]]
|
||||
: [];
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
decisionSteps
|
||||
.get(event.phase)
|
||||
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
|
||||
}
|
||||
if (collectDecisions && worldRef)
|
||||
collectPlayAudit(
|
||||
worldRef,
|
||||
'decision-trace',
|
||||
() => {
|
||||
if (event.kind === 'DECISION_START') {
|
||||
decisionSteps.set(event.phase, []);
|
||||
const heads = asRecord(currentNation?.meta._playAuditPolicy);
|
||||
decisionPolicyRefs.set(
|
||||
event.phase,
|
||||
Object.fromEntries(
|
||||
AUDIT_POLICY_AREAS.flatMap((area) => {
|
||||
const head = asRecord(heads[area]);
|
||||
return head.serverId === auditServerId &&
|
||||
typeof head.id === 'string'
|
||||
? [[area, head.id]]
|
||||
: [];
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
decisionSteps
|
||||
.get(event.phase)
|
||||
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
|
||||
},
|
||||
undefined
|
||||
);
|
||||
options.onDecisionTrace?.(event);
|
||||
}
|
||||
: undefined;
|
||||
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
|
||||
const steps = decisionSteps.get(phase);
|
||||
const first = steps?.[0];
|
||||
if (!collectDecisions || !first || !steps) return;
|
||||
const { generalId, nationId, cityId, npcState, year, month, tick } = first;
|
||||
steps.push({
|
||||
...attempt,
|
||||
sequence: storedDecisionSequence++,
|
||||
phase,
|
||||
generalId,
|
||||
nationId,
|
||||
cityId,
|
||||
npcState,
|
||||
year,
|
||||
month,
|
||||
tick,
|
||||
});
|
||||
if (!worldRef) return;
|
||||
collectPlayAudit(
|
||||
worldRef,
|
||||
'decision-summary',
|
||||
() => {
|
||||
const steps = decisionSteps.get(phase);
|
||||
const first = steps?.[0];
|
||||
if (!collectDecisions || !first || !steps) return;
|
||||
const { generalId, nationId, cityId, npcState, year, month, tick } = first;
|
||||
steps.push({
|
||||
...attempt,
|
||||
sequence: storedDecisionSequence++,
|
||||
phase,
|
||||
generalId,
|
||||
nationId,
|
||||
cityId,
|
||||
npcState,
|
||||
year,
|
||||
month,
|
||||
tick,
|
||||
});
|
||||
},
|
||||
undefined
|
||||
);
|
||||
};
|
||||
const finishDecision = (
|
||||
phase: 'general' | 'nation',
|
||||
@@ -1135,64 +1151,72 @@ export const createReservedTurnHandler = async (options: {
|
||||
blockedReason?: string;
|
||||
}
|
||||
): void => {
|
||||
const steps = decisionSteps.get(phase);
|
||||
const first = steps?.[0];
|
||||
const last = steps?.find((step) => step.kind === 'DECISION_END');
|
||||
if (
|
||||
!collectDecisions ||
|
||||
!auditServerId ||
|
||||
!worldRef ||
|
||||
!steps ||
|
||||
first?.kind !== 'DECISION_START' ||
|
||||
last?.kind !== 'DECISION_END'
|
||||
)
|
||||
return;
|
||||
const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime);
|
||||
const revision = worldRef.getGameClockState().revision;
|
||||
const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision);
|
||||
const execution = steps.at(-1);
|
||||
auditDecisions.push({
|
||||
id: auditPolicyHash([executionId, phase]),
|
||||
serverId: auditServerId,
|
||||
executionId,
|
||||
phase,
|
||||
generalId: first.generalId,
|
||||
nationId: first.nationId,
|
||||
cityId: first.cityId,
|
||||
npcState: first.npcState,
|
||||
year: first.year,
|
||||
month: first.month,
|
||||
tick,
|
||||
summary: {
|
||||
schemaVersion: 1,
|
||||
coverage: 'PROCEDURES',
|
||||
executionCoverage: 'ATTEMPTS',
|
||||
executionStatus:
|
||||
execution?.kind === 'EXECUTION_ATTEMPT' && execution.preparation
|
||||
? 'PREPARING'
|
||||
: execution?.kind === 'EXECUTION_ATTEMPT' &&
|
||||
execution.checks.some((check) => check.stage === 'BLOCK')
|
||||
? 'BLOCKED'
|
||||
: 'RESOLVED',
|
||||
clockRevision: revision,
|
||||
codeVersion: auditCodeVersion ?? null,
|
||||
policyRefs: decisionPolicyRefs.get(phase) ?? {},
|
||||
requestedAction: first.reservedAction,
|
||||
selectedAction: last.action,
|
||||
selectedReason: last.reason,
|
||||
executedAction: outcome.actionKey,
|
||||
completed: outcome.completed ?? null,
|
||||
usedFallback:
|
||||
outcome.usedFallback ||
|
||||
steps.some(
|
||||
(step) =>
|
||||
step.kind === 'EXECUTION_ATTEMPT' &&
|
||||
(step.usedFallback || step.alternativeAction !== null)
|
||||
),
|
||||
blockedReason: outcome.blockedReason ?? null,
|
||||
if (!worldRef) return;
|
||||
collectPlayAudit(
|
||||
worldRef,
|
||||
'decision-summary',
|
||||
() => {
|
||||
const steps = decisionSteps.get(phase);
|
||||
const first = steps?.[0];
|
||||
const last = steps?.find((step) => step.kind === 'DECISION_END');
|
||||
if (
|
||||
!collectDecisions ||
|
||||
!auditServerId ||
|
||||
!worldRef ||
|
||||
!steps ||
|
||||
first?.kind !== 'DECISION_START' ||
|
||||
last?.kind !== 'DECISION_END'
|
||||
)
|
||||
return;
|
||||
const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime);
|
||||
const revision = worldRef.getGameClockState().revision;
|
||||
const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision);
|
||||
const execution = steps.at(-1);
|
||||
auditDecisions.push({
|
||||
id: auditPolicyHash([executionId, phase]),
|
||||
serverId: auditServerId,
|
||||
executionId,
|
||||
phase,
|
||||
generalId: first.generalId,
|
||||
nationId: first.nationId,
|
||||
cityId: first.cityId,
|
||||
npcState: first.npcState,
|
||||
year: first.year,
|
||||
month: first.month,
|
||||
tick,
|
||||
summary: {
|
||||
schemaVersion: 1,
|
||||
coverage: 'PROCEDURES',
|
||||
executionCoverage: 'ATTEMPTS',
|
||||
executionStatus:
|
||||
execution?.kind === 'EXECUTION_ATTEMPT' && execution.preparation
|
||||
? 'PREPARING'
|
||||
: execution?.kind === 'EXECUTION_ATTEMPT' &&
|
||||
execution.checks.some((check) => check.stage === 'BLOCK')
|
||||
? 'BLOCKED'
|
||||
: 'RESOLVED',
|
||||
clockRevision: revision,
|
||||
codeVersion: auditCodeVersion ?? null,
|
||||
policyRefs: decisionPolicyRefs.get(phase) ?? {},
|
||||
requestedAction: first.reservedAction,
|
||||
selectedAction: last.action,
|
||||
selectedReason: last.reason,
|
||||
executedAction: outcome.actionKey,
|
||||
completed: outcome.completed ?? null,
|
||||
usedFallback:
|
||||
outcome.usedFallback ||
|
||||
steps.some(
|
||||
(step) =>
|
||||
step.kind === 'EXECUTION_ATTEMPT' &&
|
||||
(step.usedFallback || step.alternativeAction !== null)
|
||||
),
|
||||
blockedReason: outcome.blockedReason ?? null,
|
||||
},
|
||||
steps,
|
||||
});
|
||||
},
|
||||
steps,
|
||||
});
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js';
|
||||
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
|
||||
import { initializeAuditPolicies } from '../playAudit/policy.js';
|
||||
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
|
||||
@@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: {
|
||||
const calendarHandler = composeCalendarHandlers(
|
||||
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
|
||||
options.monthlyEventHandler,
|
||||
createCityOfficeRequestCalendarHandler(options.getWorld),
|
||||
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
|
||||
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
|
||||
createPlayAuditHandler(options.getWorld),
|
||||
@@ -937,10 +939,11 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
|
||||
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
|
||||
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
|
||||
const policyHeadsRestored = await dbHooks.restoreMissingAuditPolicyHeads();
|
||||
initializeAuditPolicies(world);
|
||||
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
|
||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized);
|
||||
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized || policyHeadsRestored);
|
||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
|
||||
import type {
|
||||
TurnDaemonCommandHandler,
|
||||
TurnDaemonCommand,
|
||||
@@ -174,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
};
|
||||
|
||||
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'tournamentAdjustGeneral',
|
||||
'troopCreate',
|
||||
'troopJoin',
|
||||
'troopExit',
|
||||
@@ -187,6 +189,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'changePermission',
|
||||
'kick',
|
||||
'appoint',
|
||||
'cityOfficeRequest',
|
||||
'voteReward',
|
||||
'syncDiplomaticResponse',
|
||||
] as const satisfies readonly TurnDaemonCommand['type'][];
|
||||
@@ -625,11 +628,84 @@ async function handleSetNpcPolicy(
|
||||
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
|
||||
}
|
||||
|
||||
async function handleTournamentAdjustGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const general = ctx.world.getGeneralById(command.generalId);
|
||||
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
|
||||
if (!general || general.userId !== command.userId) {
|
||||
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
|
||||
}
|
||||
const gold = general.gold + command.goldDelta;
|
||||
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
|
||||
}
|
||||
const meta = { ...general.meta };
|
||||
if (command.betGoldDelta !== undefined) {
|
||||
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
|
||||
const betgold = current + command.betGoldDelta;
|
||||
if (!Number.isFinite(betgold) || betgold < 0) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
|
||||
}
|
||||
meta.betgold = betgold;
|
||||
}
|
||||
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
|
||||
ctx.world.updateGeneral(command.generalId, { gold, meta });
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleTournamentSeedNpcBets(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
if (ctx.commandDb) {
|
||||
const event = command.requestId
|
||||
? await ctx.commandDb.inputEvent.findUnique({
|
||||
where: { requestId: command.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true },
|
||||
})
|
||||
: null;
|
||||
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
|
||||
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
|
||||
}
|
||||
}
|
||||
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
|
||||
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
|
||||
}
|
||||
const processedGeneralIds: number[] = [];
|
||||
const skippedGeneralIds: number[] = [];
|
||||
for (const bet of command.bets) {
|
||||
const general = ctx.world.getGeneralById(bet.generalId);
|
||||
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
|
||||
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
|
||||
skippedGeneralIds.push(bet.generalId);
|
||||
continue;
|
||||
}
|
||||
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
|
||||
ctx.world.updateGeneral(bet.generalId, {
|
||||
gold: general.gold - bet.amount,
|
||||
meta: { ...general.meta, betgold: current + bet.amount },
|
||||
});
|
||||
processedGeneralIds.push(bet.generalId);
|
||||
}
|
||||
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
|
||||
}
|
||||
|
||||
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
|
||||
reason !== undefined &&
|
||||
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
|
||||
reason
|
||||
);
|
||||
|
||||
async function handleAdjustGeneralResources(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
}
|
||||
@@ -686,6 +762,9 @@ async function handleAdjustGeneralMeta(
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return {
|
||||
type: 'adjustGeneralMeta',
|
||||
@@ -2587,6 +2666,7 @@ async function handleAppoint(
|
||||
} else {
|
||||
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
|
||||
}
|
||||
settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
|
||||
refreshActorKillturn(world, general);
|
||||
return { type: 'appoint', ok: true, generalId: command.generalId };
|
||||
}
|
||||
@@ -3243,6 +3323,12 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
changePermission: (command) =>
|
||||
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
||||
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
|
||||
cityOfficeRequest: async (command) =>
|
||||
handleCityOfficeRequest(
|
||||
ctx.world,
|
||||
command as Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
|
||||
ctx.commandDb ? await resolveOperationalAcceptedAt(ctx.commandDb, command) : new Date()
|
||||
),
|
||||
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
|
||||
tournamentRefund: (command) =>
|
||||
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
|
||||
@@ -3258,6 +3344,13 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
|
||||
setNpcPolicy: (command) =>
|
||||
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
|
||||
tournamentAdjustGeneral: (command) =>
|
||||
handleTournamentAdjustGeneral(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||
),
|
||||
tournamentSeedNpcBets: (command) =>
|
||||
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
|
||||
adjustGeneralResources: (command) =>
|
||||
handleAdjustGeneralResources(
|
||||
ctx,
|
||||
|
||||
@@ -72,6 +72,14 @@ const state: TurnWorldState = {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T09:00:00.000Z'),
|
||||
clockBaseTime: new Date('2026-07-31T09:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
lastTurnTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockPhase: 'MANUAL',
|
||||
clockWallAnchor: new Date('2026-07-31T09:00:00.000Z'),
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
meta: {
|
||||
killturn: 24,
|
||||
lastTurnTime: '2026-07-31T09:00:00.000Z',
|
||||
@@ -209,6 +217,14 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: state.tickSeconds,
|
||||
clockBaseTime: state.clockBaseTime,
|
||||
clockTick: BigInt(state.clockTick ?? 0),
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
||||
clockMode: state.clockMode,
|
||||
clockPhase: state.clockPhase,
|
||||
clockWallAnchor: state.clockWallAnchor,
|
||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
{
|
||||
type: 'tournamentAdjustGeneral',
|
||||
reason: 'tournamentBet',
|
||||
requestId: 'tournamentAdjustGeneral',
|
||||
userId,
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||
@@ -45,6 +55,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
destCityId: 1,
|
||||
officerLevel: 4,
|
||||
},
|
||||
{
|
||||
type: 'cityOfficeRequest',
|
||||
requestId: 'cityOfficeRequest',
|
||||
userId,
|
||||
generalId: 7,
|
||||
action: 'request',
|
||||
officerLevel: 4,
|
||||
},
|
||||
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
|
||||
{
|
||||
type: 'syncDiplomaticResponse',
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { readOfficeRequest, type TurnDaemonCommand } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrismaClient, type GamePrisma } from '@sammo-ts/infra';
|
||||
import { buildWorld } from './officeRequestFixture.js';
|
||||
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { EngineStateManager } from '../src/turn/engineStateManager.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import { createCityOfficeRequestCalendarHandler } from '../src/turn/cityOfficeRequests.js';
|
||||
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const worldId = 992951;
|
||||
const generalId = 992953;
|
||||
const requestId = 'integration:city-office-request';
|
||||
const constraint = 'city_office_request_rollback_test';
|
||||
integration('city office request durable persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
let hooks: DatabaseTurnHooks | undefined;
|
||||
beforeAll(async () => {
|
||||
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||
if (!schema || !/^(ci_|office_requests)/.test(schema)) throw new Error('Dedicated integration schema required');
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${constraint}`);
|
||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
||||
await db.logEntry.deleteMany({
|
||||
where: { OR: [{ nationId: worldId }, { generalId: { in: [worldId, worldId + 1, generalId] } }] },
|
||||
});
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [worldId, worldId + 1, generalId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [worldId, worldId + 1, generalId] } } });
|
||||
await db.city.deleteMany({ where: { id: worldId } });
|
||||
await db.nation.deleteMany({ where: { id: worldId } });
|
||||
await db.worldState.deleteMany();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await hooks?.close();
|
||||
await disconnect?.();
|
||||
});
|
||||
it('rolls back request and journal together, reloads pending state, then persists automatic appointment once', async () => {
|
||||
const base = buildWorld({});
|
||||
const state = {
|
||||
...base.state,
|
||||
id: worldId,
|
||||
clockBaseTime: base.state.lastTurnTime,
|
||||
clockTick: 0,
|
||||
lastTurnTick: 0,
|
||||
clockMode: 'manual' as const,
|
||||
clockPhase: 'MANUAL' as const,
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
clockWallAnchor: base.state.lastTurnTime,
|
||||
};
|
||||
const snapshot = {
|
||||
...base.snapshot,
|
||||
generals: base.snapshot.generals.map((g) => ({
|
||||
...g,
|
||||
id: 992950 + g.id,
|
||||
nationId: worldId,
|
||||
cityId: worldId,
|
||||
userId: `user-${992950 + g.id}`,
|
||||
})),
|
||||
cities: base.snapshot.cities.map((c) => ({ ...c, id: worldId, nationId: worldId })),
|
||||
nations: base.snapshot.nations.map((n) => ({
|
||||
...n,
|
||||
id: worldId,
|
||||
capitalCityId: worldId,
|
||||
chiefGeneralId: worldId,
|
||||
})),
|
||||
scenarioConfig: { ...base.snapshot.scenarioConfig!, environment: { mapName: 'che', unitSet: 'che' } },
|
||||
};
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
id: worldId,
|
||||
scenarioCode: 'office-requests',
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: state.clockBaseTime,
|
||||
clockTick: 0n,
|
||||
lastTurnTick: 0n,
|
||||
clockMode: 'manual',
|
||||
clockPhase: 'MANUAL',
|
||||
clockWallAnchor: state.clockWallAnchor,
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
config: JSON.parse(JSON.stringify(snapshot.scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: worldId,
|
||||
name: '위',
|
||||
color: '#777777',
|
||||
level: 3,
|
||||
gold: 10000,
|
||||
rice: 10000,
|
||||
tech: 0,
|
||||
typeCode: 'che_중립',
|
||||
capitalCityId: worldId,
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
await db.city.create({
|
||||
data: {
|
||||
id: worldId,
|
||||
name: '허창',
|
||||
nationId: worldId,
|
||||
level: 7,
|
||||
population: 1000,
|
||||
populationMax: 2000,
|
||||
agriculture: 1000,
|
||||
agricultureMax: 2000,
|
||||
commerce: 1000,
|
||||
commerceMax: 2000,
|
||||
security: 1000,
|
||||
securityMax: 2000,
|
||||
defence: 1000,
|
||||
defenceMax: 2000,
|
||||
wall: 1000,
|
||||
wallMax: 2000,
|
||||
trust: 80,
|
||||
trade: 100,
|
||||
region: 1,
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
await db.general.createMany({
|
||||
data: snapshot.generals.map((g) => ({
|
||||
id: g.id,
|
||||
userId: g.userId,
|
||||
name: g.name,
|
||||
nationId: g.nationId,
|
||||
cityId: g.cityId,
|
||||
npcState: g.npcState,
|
||||
officerLevel: g.officerLevel,
|
||||
leadership: g.stats.leadership,
|
||||
strength: g.stats.strength,
|
||||
intel: g.stats.intelligence,
|
||||
gold: g.gold,
|
||||
rice: g.rice,
|
||||
crew: g.crew,
|
||||
crewTypeId: g.crewTypeId,
|
||||
train: g.train,
|
||||
atmos: g.atmos,
|
||||
age: g.age,
|
||||
turnTime: g.turnTime,
|
||||
meta: g.meta,
|
||||
})),
|
||||
});
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
const manager = new EngineStateManager();
|
||||
manager.register('world', {
|
||||
capture: () => world.captureState(),
|
||||
restore: (saved) => world.restoreState(saved),
|
||||
});
|
||||
const command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }> = {
|
||||
type: 'cityOfficeRequest',
|
||||
requestId,
|
||||
userId: `user-${generalId}`,
|
||||
generalId,
|
||||
action: 'request',
|
||||
officerLevel: 4,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
actorUserId: command.userId,
|
||||
status: 'PROCESSING',
|
||||
lockedBy: 'office-test',
|
||||
leaseUntil: new Date('2099-01-01'),
|
||||
attempts: 1,
|
||||
payload: command,
|
||||
acceptedGameTick: 0n,
|
||||
acceptedClockRevision: 1n,
|
||||
acceptedDeadlineGeneration: 1n,
|
||||
processingGameTick: 0n,
|
||||
processingClockRevision: 1n,
|
||||
processingDeadlineGeneration: 1n,
|
||||
},
|
||||
});
|
||||
const execute = () =>
|
||||
manager.transaction(() =>
|
||||
hooks!.hooks.executeCommand!(requestId, async (context) => {
|
||||
const result = await handler.handle(command, context);
|
||||
if (!result) throw new Error('Missing result');
|
||||
return result;
|
||||
})
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
`ALTER TABLE input_event ADD CONSTRAINT ${constraint} CHECK (request_id <> '${requestId}' OR status <> 'SUCCEEDED')`
|
||||
);
|
||||
await expect(execute()).rejects.toThrow();
|
||||
expect(readOfficeRequest(world.getGeneralById(generalId)?.meta)).toBeNull();
|
||||
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)).toBeNull();
|
||||
expect(hooks.takeCommittedReadModelChanges()).toBeNull();
|
||||
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${constraint}`);
|
||||
await expect(execute()).resolves.toMatchObject({ type: 'cityOfficeRequest', ok: true });
|
||||
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('SUCCEEDED');
|
||||
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)?.status).toBe(
|
||||
'pending'
|
||||
);
|
||||
expect(hooks.takeCommittedReadModelChanges()).toMatchObject({ frontStatusChanged: true });
|
||||
await hooks.close();
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const reloaded: InMemoryTurnWorld = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
calendarHandler: createCityOfficeRequestCalendarHandler(() => reloaded),
|
||||
});
|
||||
const pending = readOfficeRequest(reloaded.getGeneralById(generalId)?.meta)!;
|
||||
expect(pending.status).toBe('pending');
|
||||
hooks = await createDatabaseTurnHooks(databaseUrl!, reloaded);
|
||||
const turnTime = reloaded.gameTickToDate(pending.dueTick);
|
||||
await reloaded.advanceMonth(turnTime);
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: turnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
const saved = await db.general.findUniqueOrThrow({ where: { id: generalId } });
|
||||
expect(saved.officerLevel).toBe(4);
|
||||
expect(readOfficeRequest(saved.meta)?.status).toBe('approved');
|
||||
expect((await db.city.findUniqueOrThrow({ where: { id: worldId } })).meta).toMatchObject({ officer_set: 16 });
|
||||
const count = await db.logEntry.count({ where: { generalId } });
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: turnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
expect(await db.logEntry.count({ where: { generalId } })).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GAME_TICKS_PER_TURN, readOfficeRequest, shiftOfficeRequestDeadline } from '@sammo-ts/common';
|
||||
import { buildGeneral, buildWorld } from './officeRequestFixture.js';
|
||||
import {
|
||||
handleCityOfficeRequest,
|
||||
settleCityOfficeRequests,
|
||||
createCityOfficeRequestCalendarHandler,
|
||||
} from '../src/turn/cityOfficeRequests.js';
|
||||
const acceptedAt = new Date('0185-01-01T00:00:00Z');
|
||||
const request = (world: ReturnType<typeof buildWorld>['world'], generalId = 3) =>
|
||||
handleCityOfficeRequest(
|
||||
world,
|
||||
{ type: 'cityOfficeRequest', userId: `user-${generalId}`, generalId, action: 'request', officerLevel: 4 },
|
||||
acceptedAt
|
||||
);
|
||||
const pending = (world: ReturnType<typeof buildWorld>['world']) => readOfficeRequest(world.getGeneralById(3)?.meta)!;
|
||||
const decide = (
|
||||
world: ReturnType<typeof buildWorld>['world'],
|
||||
action: 'approve' | 'reject' | 'withdraw',
|
||||
generalId = 2
|
||||
) =>
|
||||
handleCityOfficeRequest(
|
||||
world,
|
||||
{
|
||||
type: 'cityOfficeRequest',
|
||||
userId: `user-${generalId}`,
|
||||
generalId,
|
||||
action,
|
||||
targetGeneralId: 3,
|
||||
officeRequestId: pending(world).id,
|
||||
},
|
||||
acceptedAt
|
||||
);
|
||||
|
||||
describe('city office request policy and lifecycle', () => {
|
||||
it.each([null, 2, 3])('defaults vacancy / N / M (%s) to approval only at the deadline', (npc) => {
|
||||
const { world } = buildWorld({
|
||||
generals: [
|
||||
buildGeneral(2, { officerLevel: 5 }),
|
||||
buildGeneral(3),
|
||||
...(npc === null
|
||||
? []
|
||||
: [
|
||||
buildGeneral(4, {
|
||||
userId: null,
|
||||
npcState: npc,
|
||||
officerLevel: 4,
|
||||
meta: { killturn: 12, officerCity: 1 },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
});
|
||||
expect(request(world)).toMatchObject({ ok: true });
|
||||
const entry = pending(world);
|
||||
expect(entry).toMatchObject({ defaultDecision: 'approve', status: 'pending' });
|
||||
expect(entry.dueTick - entry.createdTick).toBeGreaterThanOrEqual(GAME_TICKS_PER_TURN);
|
||||
settleCityOfficeRequests(world, entry.dueTick - 1);
|
||||
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
|
||||
settleCityOfficeRequests(world, entry.dueTick);
|
||||
expect(pending(world).status).toBe('approved');
|
||||
expect(world.getGeneralById(3)).toMatchObject({ officerLevel: 4, meta: { officerCity: 1, officer_city: 1 } });
|
||||
expect(Number(world.getCityById(1)?.meta.officer_set) & 16).toBe(16);
|
||||
if (npc !== null) expect(world.getGeneralById(4)?.officerLevel).toBe(1);
|
||||
settleCityOfficeRequests(world, entry.dueTick + GAME_TICKS_PER_TURN);
|
||||
expect(pending(world).status).toBe('approved');
|
||||
});
|
||||
it.each(['absent', 'secret'] as const)('defaults %s to rejection but lets a chief explicitly approve', (reason) => {
|
||||
for (const manual of [false, true]) {
|
||||
const { world } = buildWorld({
|
||||
generals: [
|
||||
buildGeneral(2, { officerLevel: 5 }),
|
||||
buildGeneral(3, reason === 'secret' ? { meta: { killturn: 12, belong: 0 } } : {}),
|
||||
...(reason === 'absent'
|
||||
? [buildGeneral(4, { cityId: 2, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } })]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
expect(request(world)).toMatchObject({ ok: true });
|
||||
expect(pending(world)).toMatchObject({ defaultDecision: 'reject', defaultReason: reason });
|
||||
if (manual) expect(decide(world, 'approve')).toMatchObject({ ok: true });
|
||||
else settleCityOfficeRequests(world, pending(world).dueTick);
|
||||
expect(pending(world).status).toBe(manual ? 'approved' : 'rejected');
|
||||
}
|
||||
});
|
||||
it.each([0, 1])('does not replace a stationed user / possessed general (%s)', (npcState) => {
|
||||
const { world } = buildWorld({
|
||||
generals: [
|
||||
buildGeneral(3),
|
||||
buildGeneral(4, { npcState, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } }),
|
||||
],
|
||||
});
|
||||
expect(request(world)).toMatchObject({ ok: false, reason: '재직자가 도시에 체류 중' });
|
||||
});
|
||||
it('lets chiefs approve or reject default approval, and permits owner withdrawal', () => {
|
||||
for (const action of ['approve', 'reject', 'withdraw'] as const) {
|
||||
const { world } = buildWorld({});
|
||||
request(world);
|
||||
expect(decide(world, action, action === 'withdraw' ? 3 : 2)).toMatchObject({ ok: true });
|
||||
expect(pending(world).status).toBe(
|
||||
{ approve: 'approved', reject: 'rejected', withdraw: 'withdrawn' }[action]
|
||||
);
|
||||
expect(request(world).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
it('rejects forged actors, ordinary approvers, foreign chiefs and stale request IDs', () => {
|
||||
const { world } = buildWorld({});
|
||||
request(world);
|
||||
expect(decide(world, 'approve', 3).ok).toBe(false);
|
||||
world.updateGeneral(2, { nationId: 9 });
|
||||
expect(decide(world, 'approve').ok).toBe(false);
|
||||
expect(
|
||||
handleCityOfficeRequest(
|
||||
world,
|
||||
{
|
||||
type: 'cityOfficeRequest',
|
||||
userId: 'forged',
|
||||
generalId: 3,
|
||||
action: 'withdraw',
|
||||
officeRequestId: pending(world).id,
|
||||
},
|
||||
acceptedAt
|
||||
).ok
|
||||
).toBe(false);
|
||||
expect(
|
||||
handleCityOfficeRequest(
|
||||
world,
|
||||
{
|
||||
type: 'cityOfficeRequest',
|
||||
userId: 'user-3',
|
||||
generalId: 3,
|
||||
action: 'withdraw',
|
||||
officeRequestId: 'stale',
|
||||
},
|
||||
acceptedAt
|
||||
).ok
|
||||
).toBe(false);
|
||||
expect(pending(world).status).toBe('pending');
|
||||
});
|
||||
it.each(['move', 'nation', 'owner', 'occupation', 'lock', 'stat', 'penalty'] as const)(
|
||||
'cancels after %s changes before settlement',
|
||||
(change) => {
|
||||
const { world } = buildWorld({});
|
||||
request(world);
|
||||
const general = world.getGeneralById(3)!;
|
||||
if (change === 'move') world.updateGeneral(3, { cityId: 2 });
|
||||
if (change === 'nation') world.updateGeneral(3, { nationId: 2 });
|
||||
if (change === 'owner') world.updateGeneral(3, { userId: 'other' });
|
||||
if (change === 'occupation') world.updateCity(1, { nationId: 2 });
|
||||
if (change === 'lock') world.updateCity(1, { meta: { officer_set: 16 } });
|
||||
if (change === 'stat') world.updateGeneral(3, { stats: { ...general.stats, strength: 0 } });
|
||||
if (change === 'penalty') world.updateGeneral(3, { penalty: { noChief: true } });
|
||||
settleCityOfficeRequests(world, pending(world).dueTick);
|
||||
expect(pending(world).status).toBe('cancelled');
|
||||
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
|
||||
}
|
||||
);
|
||||
it('does not auto-grant newly restricted secrets or upgrade a stored rejection', () => {
|
||||
const { world } = buildWorld({});
|
||||
request(world);
|
||||
world.updateNation(1, { meta: { secretlimit: 10 } });
|
||||
settleCityOfficeRequests(world, pending(world).dueTick);
|
||||
expect(pending(world).status).toBe('rejected');
|
||||
});
|
||||
it('reserves a slot against other applicants but not a manual chief appointment', async () => {
|
||||
const { world, handler } = buildWorld({
|
||||
generals: [buildGeneral(2, { officerLevel: 5 }), buildGeneral(3), buildGeneral(4)],
|
||||
});
|
||||
request(world);
|
||||
expect(request(world, 4).ok).toBe(false);
|
||||
expect(
|
||||
await handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 4,
|
||||
destCityId: 1,
|
||||
officerLevel: 4,
|
||||
})
|
||||
).toMatchObject({ ok: true });
|
||||
expect(pending(world).status).toBe('cancelled');
|
||||
expect(world.getGeneralById(4)?.officerLevel).toBe(4);
|
||||
});
|
||||
it('uses the monthly calendar boundary and survives a world snapshot reload', async () => {
|
||||
const { world } = buildWorld({});
|
||||
request(world);
|
||||
const saved = JSON.parse(JSON.stringify(world.getGeneralById(3)?.meta));
|
||||
const { world: reloaded } = buildWorld({ generals: [buildGeneral(3, { meta: saved })] });
|
||||
await createCityOfficeRequestCalendarHandler(() => reloaded).onMonthChanged?.({
|
||||
previousYear: 185,
|
||||
previousMonth: 1,
|
||||
currentYear: 185,
|
||||
currentMonth: 2,
|
||||
turnTime: reloaded.gameTickToDate(pending(reloaded).dueTick),
|
||||
});
|
||||
expect(pending(reloaded).status).toBe('approved');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a full review month for requests accepted just before a month boundary', () => {
|
||||
const { world } = buildWorld({
|
||||
clock: {
|
||||
clockBaseTime: acceptedAt,
|
||||
clockWallAnchor: acceptedAt,
|
||||
clockMode: 'manual',
|
||||
clockTick: GAME_TICKS_PER_TURN - 1,
|
||||
lastTurnTick: 0,
|
||||
},
|
||||
});
|
||||
expect(request(world)).toMatchObject({ ok: true });
|
||||
expect(pending(world).dueTick).toBe(2 * GAME_TICKS_PER_TURN);
|
||||
settleCityOfficeRequests(world, GAME_TICKS_PER_TURN);
|
||||
expect(pending(world).status).toBe('pending');
|
||||
});
|
||||
|
||||
it('releases the quarterly applicant limit next quarter while preserving same-quarter refusal', () => {
|
||||
const { world } = buildWorld({});
|
||||
request(world);
|
||||
decide(world, 'reject');
|
||||
expect(request(world).ok).toBe(false);
|
||||
const previous = pending(world);
|
||||
world.updateGeneral(3, {
|
||||
meta: { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...previous, quarter: previous.quarter - 1 } },
|
||||
});
|
||||
expect(request(world).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels pending requests before quarterly locks reset', async () => {
|
||||
const { world } = buildWorld({});
|
||||
request(world);
|
||||
world.updateCity(1, { meta: { officer_set: 16 } });
|
||||
await createCityOfficeRequestCalendarHandler(() => world).beforeMonthChanged?.({
|
||||
previousYear: 185,
|
||||
previousMonth: 3,
|
||||
currentYear: 185,
|
||||
currentMonth: 4,
|
||||
turnTime: acceptedAt,
|
||||
});
|
||||
expect(pending(world).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('does not relax a stored secret rejection when tenure increases', () => {
|
||||
const { world } = buildWorld({ generals: [buildGeneral(3, { meta: { killturn: 12, belong: 0 } })] });
|
||||
request(world);
|
||||
world.updateGeneral(3, { meta: { ...world.getGeneralById(3)!.meta, belong: 10 } });
|
||||
settleCityOfficeRequests(world, pending(world).dueTick);
|
||||
expect(pending(world).status).toBe('rejected');
|
||||
});
|
||||
|
||||
it('moves pending deadlines on backlog rebase and preserves occurrence ticks', () => {
|
||||
const anchor = new Date('2026-01-01T00:00:00Z');
|
||||
const { world } = buildWorld({
|
||||
clock: { clockBaseTime: anchor, clockWallAnchor: anchor, clockMode: 'realtime', clockTick: 0, lastTurnTick: 0 },
|
||||
});
|
||||
expect(
|
||||
handleCityOfficeRequest(
|
||||
world,
|
||||
{ type: 'cityOfficeRequest', userId: 'user-3', generalId: 3, action: 'request', officerLevel: 4 },
|
||||
anchor
|
||||
)
|
||||
).toMatchObject({ ok: true });
|
||||
const before = pending(world);
|
||||
const shifted = world.rebaseRealtimeBacklog(new Date(anchor.getTime() + 13 * 600_000));
|
||||
expect(shifted?.shiftedTicks).toBe(12 * GAME_TICKS_PER_TURN);
|
||||
expect(pending(world)).toMatchObject({
|
||||
createdTick: before.createdTick,
|
||||
dueTick: before.dueTick + shifted!.shiftedTicks,
|
||||
});
|
||||
const completed = { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...before, status: 'approved' } };
|
||||
expect(shiftOfficeRequestDeadline(completed, 10)).toEqual(completed);
|
||||
expect(shiftOfficeRequestDeadline(world.getGeneralById(3)!.meta, 10, pending(world).dueTick + 1)).toEqual(
|
||||
world.getGeneralById(3)!.meta
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the existing secret-limit fallback for malformed metadata', () => {
|
||||
const { world } = buildWorld({
|
||||
nationMeta: { secretlimit: 'invalid' },
|
||||
generals: [buildGeneral(3, { meta: { killturn: 12, belong: 'invalid' } })],
|
||||
});
|
||||
expect(request(world)).toMatchObject({ ok: true });
|
||||
expect(pending(world).defaultDecision).toBe('reject');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
|
||||
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery, readOfficeRequest } from '@sammo-ts/common';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
readTurnRuntimeReady,
|
||||
@@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
meta:
|
||||
index === 0
|
||||
? {
|
||||
cityOfficeRequest: {
|
||||
id: '1:760',
|
||||
generalId: 1,
|
||||
userId: 'clock-user',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
officerLevel: 4,
|
||||
incumbentId: 0,
|
||||
quarter: 760,
|
||||
createdTick: initialTick - 100,
|
||||
dueTick: reselectionTick,
|
||||
defaultDecision: 'approve',
|
||||
defaultReason: 'vacant',
|
||||
status: 'pending',
|
||||
},
|
||||
next_change_tick: reselectionTick,
|
||||
next_change: clock.tickToDate(reselectionTick).toISOString(),
|
||||
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
|
||||
@@ -922,6 +937,11 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
);
|
||||
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
|
||||
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
|
||||
expect(readOfficeRequest(shiftedReselectionMeta)).toMatchObject({
|
||||
createdTick: initialTick - 100,
|
||||
dueTick: reselectionTick + reconciled.shiftTicks,
|
||||
status: 'pending',
|
||||
});
|
||||
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
|
||||
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
|
||||
);
|
||||
|
||||
@@ -274,69 +274,79 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
|
||||
const requestId = 'integration:engine:stale-owner-replay';
|
||||
const command: TurnDaemonCommand = {
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
|
||||
'stores a stale-owner %s rejection once and never redispatches it',
|
||||
async (type) => {
|
||||
const requestId = `integration:engine:stale-owner-replay:${type}`;
|
||||
const command: TurnDaemonCommand =
|
||||
type === 'vacation'
|
||||
? { type, requestId, userId: 'old-owner', generalId: 7 }
|
||||
: {
|
||||
type,
|
||||
reason: 'tournamentBet',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
actorUserId: command.userId,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const handle = vi.spyOn(handler, 'handle');
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
await owner.publishCommandResult(requestId, result!);
|
||||
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: command.userId,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const handle = vi.spyOn(handler, 'handle');
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
await owner.publishCommandResult(requestId, result!);
|
||||
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: command.userId,
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
result,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
result,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
|
||||
'handles pre-opening user commands in %s without treating them as scheduled turns',
|
||||
@@ -630,6 +640,8 @@ integration('database command queue', () => {
|
||||
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
||||
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
||||
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
||||
const atomicBetId = 'integration:engine:suspended-atomic-bet';
|
||||
const atomicJoinId = 'integration:engine:suspended-atomic-join';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
@@ -679,6 +691,41 @@ integration('database command queue', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
requestId: atomicBetId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentBet',
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: atomicJoinId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicJoinId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentJoin',
|
||||
goldDelta: -200,
|
||||
minGoldAfter: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await expect(queue.drain()).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
||||
@@ -688,7 +735,15 @@ integration('database command queue', () => {
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
reason: 'tournamentBet',
|
||||
}),
|
||||
]);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
});
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
processingGameTick: 321n,
|
||||
|
||||
@@ -224,6 +224,14 @@ integration('monthly catalog boundary persistence', () => {
|
||||
currentYear: 190,
|
||||
currentMonth: 12,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: new Date('2026-07-25T01:50:00.000Z'),
|
||||
clockTick: 0n,
|
||||
lastTurnTick: 0n,
|
||||
clockMode: 'manual',
|
||||
clockPhase: 'MANUAL',
|
||||
clockWallAnchor: new Date('2026-07-25T01:50:00.000Z'),
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
config: scenarioConfig,
|
||||
meta: {
|
||||
hiddenSeed: 'monthly-boundary-core-catalog',
|
||||
|
||||
@@ -157,6 +157,23 @@ integration('monthly diplomacy persistence', () => {
|
||||
});
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
// An API writer may have recorded a gap since this daemon loaded its world.
|
||||
await db.worldState.update({
|
||||
where: { id: worldRow.id },
|
||||
data: {
|
||||
meta: {
|
||||
...world.getState().meta,
|
||||
playAuditGap: {
|
||||
serverId: scenarioCode,
|
||||
firstYear: 192,
|
||||
firstMonth: 12,
|
||||
lastYear: 192,
|
||||
lastMonth: 12,
|
||||
stage: 'persistence',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const checkpoint = world.captureState();
|
||||
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||
const queued = world.peekDirtyState().pendingAuditDiplomacy;
|
||||
@@ -170,11 +187,11 @@ integration('monthly diplomacy persistence', () => {
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure');
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued);
|
||||
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
|
||||
currentMonth: 1,
|
||||
currentMonth: 2,
|
||||
});
|
||||
expect(
|
||||
await db.diplomacy.findUniqueOrThrow({
|
||||
@@ -185,7 +202,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({ stateCode: 1, term: 1 });
|
||||
).toMatchObject({ stateCode: 0, term: 6 });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
|
||||
@@ -193,18 +210,25 @@ integration('monthly diplomacy persistence', () => {
|
||||
|
||||
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
|
||||
world.queueAuditDecision(decision);
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`);
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`
|
||||
);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback');
|
||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]);
|
||||
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
|
||||
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(1);
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(2);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()');
|
||||
}
|
||||
// New observations after recovery can be stored; discarded batches do not retry themselves.
|
||||
for (const event of queued) world.queueAuditDiplomacy(event);
|
||||
world.queueAuditDecision(decision);
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: '0193-02-01T00:00:00.000Z',
|
||||
processedGenerals: 0,
|
||||
@@ -313,9 +337,9 @@ integration('monthly diplomacy persistence', () => {
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('lifecycle audit fixture failure');
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(removals);
|
||||
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(0);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
|
||||
@@ -331,7 +355,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
await db.playAuditDiplomacyEvent.count({
|
||||
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
|
||||
})
|
||||
).toBe(6);
|
||||
).toBe(0);
|
||||
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
|
||||
await hooks.flushChanges();
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
||||
@@ -349,6 +373,18 @@ integration('monthly diplomacy persistence', () => {
|
||||
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' },
|
||||
})
|
||||
).toBe(6);
|
||||
const savedDecision = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
|
||||
const nation = world.getNationById(nationIds[0]!)!;
|
||||
world.updateNation(nation.id, { gold: nation.gold + 1 });
|
||||
world.queueAuditDecision({
|
||||
...decision,
|
||||
summary: { ...decision.summary, selectedReason: 'changed after update' },
|
||||
});
|
||||
await hooks.flushChanges();
|
||||
expect(await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } })).toEqual(savedDecision);
|
||||
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
|
||||
expect(world.hasPendingAuditRecords()).toBe(false);
|
||||
expect(world.getState().meta.playAuditGap).toMatchObject({ firstYear: 192, firstMonth: 12 });
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
|
||||
@@ -186,6 +187,31 @@ describe('invader monthly actions', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('preserves new nation audit heads through ruler initialization and restart', async () => {
|
||||
const harness = buildHarness();
|
||||
const { world, environment } = harness;
|
||||
world.updateWorldMeta({ serverId: 'new-nation-audit' });
|
||||
initializeAuditPolicies(world);
|
||||
const handler = createRaiseInvaderHandler({
|
||||
getWorld: () => world,
|
||||
reservedTurns: harness.reservedTurns,
|
||||
env: buildCommandEnv(scenarioConfig),
|
||||
});
|
||||
await handler([10, 150, 100, 20], environment, event);
|
||||
const created = world.peekDirtyState().createdNations;
|
||||
expect(created.length).toBeGreaterThan(0);
|
||||
for (const nation of created) {
|
||||
expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4);
|
||||
}
|
||||
world.consumeDirtyState();
|
||||
initializeAuditPolicies(world);
|
||||
expect(
|
||||
world
|
||||
.peekDirtyState()
|
||||
.pendingAuditPolicies.filter((policy) => created.some((nation) => nation.id === policy.nationId))
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
|
||||
const harness = buildHarness();
|
||||
const handler = createRaiseInvaderHandler({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
parseScenarioGeneralPoolCandidate,
|
||||
@@ -228,6 +229,21 @@ describe('RaiseNPCNation monthly action', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('preserves new nation audit heads through ruler initialization and restart', async () => {
|
||||
const { world, handler, environment } = buildHarness();
|
||||
world.updateWorldMeta({ serverId: 'new-nation-audit' });
|
||||
initializeAuditPolicies(world);
|
||||
await handler([], environment, event);
|
||||
const created = world.peekDirtyState().createdNations;
|
||||
expect(created.length).toBeGreaterThan(0);
|
||||
for (const nation of created) {
|
||||
expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4);
|
||||
}
|
||||
world.consumeDirtyState();
|
||||
initializeAuditPolicies(world);
|
||||
expect(world.peekDirtyState().pendingAuditPolicies).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
|
||||
const info = {
|
||||
generalName: '부장후보',
|
||||
@@ -337,6 +353,32 @@ describe('RaiseNPCNation monthly action', () => {
|
||||
"id": 2,
|
||||
"level": 2,
|
||||
"meta": {
|
||||
"_playAuditPolicy": {
|
||||
"DEFENCE": {
|
||||
"hash": "ffb1e0b6bbf1680af65800cfc1b166afbd68149217e2b039c95b4ce4db0d20aa",
|
||||
"id": "6329bea23c4db57a8c6bfb7d4c81e9f5a3e59171c883e9d22c5ba97dfb7d486c",
|
||||
"revision": 1,
|
||||
"serverId": "fixture-server",
|
||||
},
|
||||
"NPC_GENERAL_PRIORITY": {
|
||||
"hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062",
|
||||
"id": "28bc6ff38cc5b1b250f6a738a6745b5d581badf34bbcaf191e5e8710c17263ec",
|
||||
"revision": 1,
|
||||
"serverId": "fixture-server",
|
||||
},
|
||||
"NPC_NATION_PRIORITY": {
|
||||
"hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062",
|
||||
"id": "feefa645ba10e0fb8c2c78e0c189f9effe8a1c3d2adf4c0d866300d4123f3748",
|
||||
"revision": 1,
|
||||
"serverId": "fixture-server",
|
||||
},
|
||||
"NPC_VALUES": {
|
||||
"hash": "1906fb445a401a46f1279dbba3f3d572e9b213daf6c0f0d31cf8beeee3c77104",
|
||||
"id": "c6fe336ffe5870cc90e2abab797d050810400404f7ad51b3ceea987b21eb3baa",
|
||||
"revision": 1,
|
||||
"serverId": "fixture-server",
|
||||
},
|
||||
},
|
||||
"bill": 100,
|
||||
"can_국기변경": 1,
|
||||
"gennum": 1,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { type GeneralActionModule, type TriggerValue, type TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
export const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id,
|
||||
userId: `user-${id}`,
|
||||
name: `장수${id}`,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 70, intelligence: 70 },
|
||||
turnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 },
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 1_000,
|
||||
dedication: 2_000,
|
||||
injury: 0,
|
||||
gold: 1_500,
|
||||
rice: 1_600,
|
||||
crew: 100,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const buildWorld = (options: {
|
||||
generals?: TurnGeneral[];
|
||||
nationMeta?: Record<string, TriggerValue>;
|
||||
nationChiefGeneralId?: number | null;
|
||||
cityMeta?: Record<string, TriggerValue>;
|
||||
currentYear?: number;
|
||||
scenarioConst?: Record<string, unknown>;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule>;
|
||||
clock?: Pick<TurnWorldState, 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'>;
|
||||
}) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: options.currentYear ?? 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
meta: { killturn: 24, scenarioMeta: { startYear: 180 } },
|
||||
...options.clock,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: options.generals ?? [
|
||||
buildGeneral(1, { officerLevel: 12 }),
|
||||
buildGeneral(2, { officerLevel: 5 }),
|
||||
buildGeneral(3),
|
||||
],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '허창',
|
||||
nationId: 1,
|
||||
level: 7,
|
||||
state: 0,
|
||||
population: 1_000,
|
||||
populationMax: 2_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: options.cityMeta ?? {},
|
||||
},
|
||||
],
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#777777',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: options.nationChiefGeneralId === undefined ? 1 : options.nationChiefGeneralId,
|
||||
gold: 10_000,
|
||||
rice: 20_000,
|
||||
power: 0,
|
||||
level: 3,
|
||||
typeCode: 'che_법가',
|
||||
meta: options.nationMeta ?? {},
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { defaultGold: 1000, defaultRice: 1000, ...options.scenarioConst },
|
||||
environment: { mapName: 'test', unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'test',
|
||||
startYear: 180,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
return {
|
||||
snapshot,
|
||||
state,
|
||||
world,
|
||||
handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }),
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
|
||||
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
@@ -130,6 +130,29 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
|
||||
return world;
|
||||
};
|
||||
describe('play audit collection durability state', () => {
|
||||
it('discards collector failures without changing gameplay state and can collect again', () => {
|
||||
const world = buildWorld();
|
||||
const nations = structuredClone(world.listNations());
|
||||
const broken = vi.spyOn(world, 'listGenerals').mockImplementationOnce(() => {
|
||||
throw new Error('audit projection');
|
||||
});
|
||||
expect(() => queueAuditMonth(world)).not.toThrow();
|
||||
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(0);
|
||||
expect(world.listNations()).toEqual(nations);
|
||||
expect(world.getState().meta.playAuditGap).toMatchObject({
|
||||
stage: 'queueAuditMonth',
|
||||
firstYear: 200,
|
||||
firstMonth: 1,
|
||||
});
|
||||
broken.mockRestore();
|
||||
queueAuditMonth(world);
|
||||
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(1);
|
||||
expect(world.getState().meta.playAuditGap).toBeDefined();
|
||||
world.updateWorldMeta({ playAuditCollection: { serverId: 'yearbook-projection-test', schemaVersion: 999 } });
|
||||
expect(initializeAuditCollection(world)).toBe(false);
|
||||
expect(world.getState().meta.playAuditGap).toMatchObject({ stage: 'initializeAuditCollection' });
|
||||
});
|
||||
|
||||
it('restores decision buffers and acknowledges only the committed prefix', () => {
|
||||
const world = buildWorld();
|
||||
const first = buildAuditDecisionFixture('first');
|
||||
@@ -207,7 +230,8 @@ describe('play audit collection durability state', () => {
|
||||
world.removeNation(1);
|
||||
world.removeNation(2);
|
||||
world.updateWorldMeta({ serverId: 'yearbook-projection-test' });
|
||||
expect(() => initializeAuditDiplomacy(world, new Date('invalid'))).toThrow(RangeError);
|
||||
expect(initializeAuditDiplomacy(world, new Date('invalid'))).toBe(false);
|
||||
expect(world.getState().meta.playAuditGap).toMatchObject({ stage: 'initializeAuditDiplomacy' });
|
||||
expect(world.getState().meta.playAuditDiplomacy).toBeUndefined();
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
expect(initializeAuditDiplomacy(world)).toBe(true);
|
||||
|
||||
@@ -76,13 +76,15 @@ describe('play audit monthly projection', () => {
|
||||
it('separates humans, NPCs and troop NPCs and retains empty nations and neutral generals', () => {
|
||||
const humans = [buildGeneral(1, 1), { ...buildGeneral(2, 1), npcState: 1, gold: 0 }];
|
||||
humans[0]!.meta.dex1 = 10;
|
||||
humans[0]!.crew = 3210;
|
||||
humans[1]!.crew = 790;
|
||||
const result = buildAuditSnapshot({
|
||||
nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})],
|
||||
cities: [buildCity(1, 2)],
|
||||
generals: [
|
||||
...humans,
|
||||
{ ...buildGeneral(3, 1), npcState: 2 },
|
||||
{ ...buildGeneral(4, 1), npcState: 5 },
|
||||
{ ...buildGeneral(3, 1), npcState: 2, crew: 1200 },
|
||||
{ ...buildGeneral(4, 1), npcState: 5, crew: 800 },
|
||||
buildGeneral(5, 0),
|
||||
],
|
||||
settlements: [],
|
||||
@@ -91,12 +93,13 @@ describe('play audit monthly projection', () => {
|
||||
const nation = result.nations.find((row) => row.id === 1)!;
|
||||
expect(nation.populations.human).toMatchObject({
|
||||
count: 2,
|
||||
crew: 4000,
|
||||
gold: 2000,
|
||||
averageGold: 1000,
|
||||
averageDex: { dex1: 5 },
|
||||
});
|
||||
expect(nation.populations.npc.count).toBe(1);
|
||||
expect(nation.populations.troopNpc.count).toBe(1);
|
||||
expect(nation.populations.npc).toMatchObject({ count: 1, crew: 1200 });
|
||||
expect(nation.populations.troopNpc).toMatchObject({ count: 1, crew: 800 });
|
||||
expect(result.nations.find((row) => row.id === 2)!.populations.human.averageGold).toBeNull();
|
||||
expect(result.nations.find((row) => row.id === 0)!.populations.npc.count).toBe(1);
|
||||
expect(result.cities[0]!.nationId).toBe(2);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
withPlayAuditSavepoint,
|
||||
hashAuditDiplomacyDocument,
|
||||
type GamePrismaClient,
|
||||
type GamePrisma,
|
||||
@@ -92,7 +93,7 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('rolls initial policies, sample and collection marker back before readiness on persistence failure', async () => {
|
||||
it('keeps readiness and gameplay when initial audit persistence fails', async () => {
|
||||
const before = await clock();
|
||||
await db.$executeRawUnsafe(
|
||||
"CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$"
|
||||
@@ -101,27 +102,33 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
"CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()"
|
||||
);
|
||||
try {
|
||||
let error: unknown;
|
||||
try {
|
||||
runtime = await start();
|
||||
} catch (cause) {
|
||||
error = cause;
|
||||
}
|
||||
expect(String(error)).toContain('fixture initial audit failure');
|
||||
runtime = await start();
|
||||
expect(await db.playAuditMonth.count()).toBe(0);
|
||||
expect(await db.playAuditPolicy.count()).toBe(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count()).toBe(0);
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined();
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toBeUndefined();
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined();
|
||||
expect(
|
||||
(await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined)
|
||||
).toBe(true);
|
||||
expect((await db.turnDaemonLease.findMany()).every((lease) => !lease.clockReady)).toBe(true);
|
||||
expect(runtime.world.hasPendingAuditRecords()).toBe(false);
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditGap).toMatchObject({
|
||||
serverId,
|
||||
stage: 'persistence',
|
||||
});
|
||||
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
|
||||
expect(await clock()).toEqual(before);
|
||||
await runtime.close();
|
||||
runtime = undefined;
|
||||
// Restart under the same broken audit table must not pause or accumulate retries.
|
||||
runtime = await start();
|
||||
expect(runtime.world.hasPendingAuditRecords()).toBe(false);
|
||||
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
|
||||
} finally {
|
||||
await runtime?.close();
|
||||
runtime = undefined;
|
||||
await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()');
|
||||
// Independent baseline fixture for the following normal-start tests.
|
||||
await db.playAuditDiplomacyEvent.deleteMany();
|
||||
await db.$executeRawUnsafe(
|
||||
"UPDATE world_state SET meta = meta - 'playAuditCollection' - 'playAuditDocuments' - 'playAuditDiplomacy' - 'playAuditGap'"
|
||||
);
|
||||
await db.$executeRawUnsafe("UPDATE nation SET meta = meta - '_playAuditPolicy'");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -235,6 +242,64 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
).rejects.toMatchObject({ code: 'P2002' });
|
||||
}, 30_000);
|
||||
|
||||
it('restores missing policy heads from immutable history before restart without rewriting history', async () => {
|
||||
await runtime?.close();
|
||||
runtime = undefined;
|
||||
const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } });
|
||||
const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } });
|
||||
const { _playAuditPolicy: heads, ...meta } = asRecord(nation.meta);
|
||||
await db.nation.update({ where: { id: nation.id }, data: { meta: meta as GamePrisma.InputJsonObject } });
|
||||
runtime = await start();
|
||||
expect(
|
||||
asRecord((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).meta)._playAuditPolicy
|
||||
).toEqual(heads);
|
||||
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
|
||||
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
|
||||
await runtime.close();
|
||||
runtime = undefined;
|
||||
runtime = await start();
|
||||
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
|
||||
}, 30_000);
|
||||
|
||||
it('links an observed policy gap to the recovered head and keeps it idempotent', async () => {
|
||||
await runtime?.close();
|
||||
runtime = undefined;
|
||||
const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } });
|
||||
const meta = asRecord(nation.meta);
|
||||
const heads = asRecord(meta._playAuditPolicy);
|
||||
const previous = asRecord(heads.DEFENCE);
|
||||
const { DEFENCE: _lost, ...remainingHeads } = heads;
|
||||
await db.nation.update({
|
||||
where: { id: nation.id },
|
||||
data: {
|
||||
meta: { ...meta, scout: 1, _playAuditPolicy: remainingHeads } as GamePrisma.InputJsonObject,
|
||||
},
|
||||
});
|
||||
runtime = await start();
|
||||
const rows = await db.playAuditPolicy.findMany({
|
||||
where: { serverId, nationId: nation.id, area: 'DEFENCE' },
|
||||
orderBy: { revision: 'asc' },
|
||||
});
|
||||
expect(rows.at(-1)).toMatchObject({
|
||||
source: 'OBSERVED_GAP',
|
||||
previousId: previous.id,
|
||||
revision: Number(previous.revision) + 1,
|
||||
actor: null,
|
||||
before: null,
|
||||
after: { scout: 1 },
|
||||
});
|
||||
expect(asRecord((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).meta).scout).toBe(1);
|
||||
await runtime.close();
|
||||
runtime = undefined;
|
||||
runtime = await start();
|
||||
expect(
|
||||
await db.playAuditPolicy.findMany({
|
||||
where: { serverId, nationId: nation.id, area: 'DEFENCE' },
|
||||
orderBy: { revision: 'asc' },
|
||||
})
|
||||
).toEqual(rows);
|
||||
}, 30_000);
|
||||
|
||||
it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => {
|
||||
await runtime?.close();
|
||||
runtime = undefined;
|
||||
@@ -279,4 +344,34 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
runtime = await start();
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker);
|
||||
}, 30_000);
|
||||
it('isolates missing audit tables and bounded SQL waits but propagates core write failures', async () => {
|
||||
await runtime?.close();
|
||||
runtime = undefined;
|
||||
const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } });
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.nation.update({ where: { id: nation.id }, data: { gold: nation.gold + 1 } });
|
||||
const missing = await withPlayAuditSavepoint(tx, () =>
|
||||
tx.$queryRawUnsafe('SELECT * FROM nonexistent_audit_fixture_table')
|
||||
);
|
||||
expect(missing.ok).toBe(false);
|
||||
const [settings] = await tx.$queryRaw<
|
||||
Array<{ timeout: string }>
|
||||
>`SELECT current_setting('statement_timeout') AS timeout`;
|
||||
const slow = await withPlayAuditSavepoint(tx, () => tx.$queryRawUnsafe('SELECT pg_sleep(2)::text'));
|
||||
expect(slow.ok).toBe(false);
|
||||
const [restored] = await tx.$queryRaw<
|
||||
Array<{ timeout: string }>
|
||||
>`SELECT current_setting('statement_timeout') AS timeout`;
|
||||
expect(restored).toEqual(settings);
|
||||
});
|
||||
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
|
||||
await expect(
|
||||
db.$transaction(async (tx) => {
|
||||
await tx.nation.update({ where: { id: nation.id }, data: { gold: nation.gold + 2 } });
|
||||
expect((await withPlayAuditSavepoint(tx, async () => 'audit-ok')).ok).toBe(true);
|
||||
throw new Error('core game write failed');
|
||||
})
|
||||
).rejects.toThrow('core game write failed');
|
||||
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,11 +504,11 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [1],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusGeneralIds: [7],
|
||||
frontStatusNationIds: [1],
|
||||
lobbyGeneralIds: [],
|
||||
contactsChanged: true,
|
||||
frontStatusChanged: false,
|
||||
frontStatusChanged: true,
|
||||
lobbyChanged: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,83 @@ const buildWorld = (
|
||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
};
|
||||
|
||||
describe('tournament mutation ownership', () => {
|
||||
it('checks the current owner and commits gold plus bet statistics together', async () => {
|
||||
const general = { ...buildGeneral(1), userId: 'owner' };
|
||||
const world = buildWorld([general]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'tournamentAdjustGeneral' as const,
|
||||
reason: 'tournamentBet' as const,
|
||||
userId: 'owner',
|
||||
generalId: 1,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
|
||||
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
world.updateGeneral(1, { userId: 'new-owner' });
|
||||
const transferred = structuredClone(world.getGeneralById(1));
|
||||
for (const goldDelta of [-100, 100]) {
|
||||
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(transferred);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
|
||||
const world = buildWorld([
|
||||
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
|
||||
buildGeneral(2, {}, 2),
|
||||
{ ...buildGeneral(3, {}, 2), gold: 509 },
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const possessed = structuredClone(world.getGeneralById(1));
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
ok: true,
|
||||
processedGeneralIds: [2],
|
||||
skippedGeneralIds: [1, 3],
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(possessed);
|
||||
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
|
||||
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
|
||||
});
|
||||
|
||||
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
|
||||
'rejects actorless legacy %s commands before mutation',
|
||||
async (reason) => {
|
||||
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
for (const command of [
|
||||
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
|
||||
{
|
||||
type: 'adjustGeneralMeta' as const,
|
||||
reason,
|
||||
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
|
||||
},
|
||||
])
|
||||
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('tournament world commands', () => {
|
||||
it('uses the current world develcost for Ref-compatible tournament prizes', () => {
|
||||
const currentWorld = buildWorld([], {
|
||||
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
|
||||
});
|
||||
|
||||
it('records all four tournament types and NPC betting for at least ten generals', async () => {
|
||||
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1));
|
||||
const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
|
||||
const world = buildWorld(generals);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
|
||||
}
|
||||
}
|
||||
await handler.handle({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: generals.map((general) => ({
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: 1_000 },
|
||||
})),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
|
||||
});
|
||||
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
|
||||
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
|
||||
await handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
@@ -618,3 +619,18 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex
|
||||
});
|
||||
expect(workerUrls.some((url) => url.includes('battleSimulator.worker'))).toBe(true);
|
||||
});
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`전투 시뮬레이터 요약표 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await installApi(page, {
|
||||
hasGeneral: true,
|
||||
requests: [],
|
||||
preparedPayloads: [],
|
||||
serverResults: [],
|
||||
prepareResponseBytes: [],
|
||||
});
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoSimulator(page);
|
||||
await verifyVerticalAlignment(page, testInfo, ['.summary-table th', '.summary-table td'], 'table');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ test('persists one chief command and exposes it to a normal nation user and anot
|
||||
selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id);
|
||||
if (!selectedTargetId) throw new Error('No reward target is available in the live command table.');
|
||||
await argumentForm.locator('select').selectOption(String(selectedTargetId));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(
|
||||
editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()
|
||||
).toHaveText('포상');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user