Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,10 @@ 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
|
||||
# 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 +107,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>;
|
||||
|
||||
@@ -66,8 +66,10 @@ const resolveMapName = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
||||
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
|
||||
export const loadMapLayoutByName = async (
|
||||
mapName: string,
|
||||
options: MapLayoutLoaderOptions = {}
|
||||
): Promise<MapLayout> => {
|
||||
const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap;
|
||||
const cached = useCache ? layoutCache.get(mapName) : undefined;
|
||||
if (cached) {
|
||||
@@ -98,3 +100,8 @@ export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOp
|
||||
}
|
||||
return layout;
|
||||
};
|
||||
|
||||
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
||||
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
|
||||
return loadMapLayoutByName(mapName, options);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
withPlayAuditSavepoint,
|
||||
persistAuditDiplomacyEvents,
|
||||
type AuditDiplomacyEventDraft,
|
||||
type GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
|
||||
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
@@ -104,6 +109,7 @@ const persistEffects = async (
|
||||
}
|
||||
): Promise<void> => {
|
||||
const changes: AuditDiplomacyEventDraft[] = [];
|
||||
let collectionFailed = false;
|
||||
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
|
||||
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
|
||||
state: row.stateCode,
|
||||
@@ -129,6 +135,7 @@ const persistEffects = async (
|
||||
},
|
||||
});
|
||||
if (audit) {
|
||||
try {
|
||||
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||
const previous = states.get(key);
|
||||
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||
@@ -145,6 +152,9 @@ const persistEffects = async (
|
||||
});
|
||||
}
|
||||
states.set(key, updated);
|
||||
} catch {
|
||||
collectionFailed = true;
|
||||
}
|
||||
}
|
||||
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
||||
const patch = effect.patch;
|
||||
@@ -159,7 +169,11 @@ const persistEffects = async (
|
||||
}
|
||||
}
|
||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
||||
await persistAuditDiplomacyEvents(db, changes);
|
||||
if (changes.length || collectionFailed)
|
||||
await withPlayAuditSavepoint(db, async (auditDb) => {
|
||||
if (collectionFailed) throw new Error('Audit relation collection failed');
|
||||
await persistAuditDiplomacyEvents(auditDb, changes);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
const MAX_PENDING_FRAMES = 32;
|
||||
const MAX_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
/** Revalidate before work and before writing its result; never retain unbounded frames. */
|
||||
export class AuthenticatedRealtimeConnection {
|
||||
private queue = Promise.resolve();
|
||||
private pending = 0;
|
||||
private ended = false;
|
||||
private readonly expiryTimer: ReturnType<typeof setTimeout>;
|
||||
private cleanup = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
readonly auth: GameSessionTokenPayload,
|
||||
private readonly response: ServerResponse,
|
||||
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
|
||||
) {
|
||||
// AccessTokenStore requires a full remaining TTL second on every lookup.
|
||||
this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999));
|
||||
this.expiryTimer.unref();
|
||||
response.once('close', this.close);
|
||||
response.once('error', this.close);
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.ended;
|
||||
}
|
||||
|
||||
whenIdle(): Promise<void> {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
onClose(cleanup: () => void): void {
|
||||
if (this.ended) cleanup();
|
||||
else this.cleanup.add(cleanup);
|
||||
}
|
||||
|
||||
private async currentAuth(): Promise<GameSessionTokenPayload | null> {
|
||||
if (this.ended) return null;
|
||||
const current = await this.resolveAuth();
|
||||
if (
|
||||
!current ||
|
||||
this.ended ||
|
||||
current.sessionId !== this.auth.sessionId ||
|
||||
current.user.id !== this.auth.user.id ||
|
||||
current.profile !== this.auth.profile
|
||||
) {
|
||||
this.close();
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
enqueue(work: (auth: GameSessionTokenPayload) => Promise<string | null>): void {
|
||||
if (this.ended) return;
|
||||
if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.pending += 1;
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
const current = await this.currentAuth();
|
||||
if (!current) return;
|
||||
const frame = await work(current);
|
||||
if (!frame || !(await this.currentAuth())) return;
|
||||
if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close();
|
||||
})
|
||||
.catch(() => {
|
||||
// Redis/auth failure is fail closed. Reconnect must authenticate again.
|
||||
this.close();
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
close = (): void => {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
clearTimeout(this.expiryTimer);
|
||||
this.response.off('close', this.close);
|
||||
this.response.off('error', this.close);
|
||||
for (const cleanup of this.cleanup) cleanup();
|
||||
this.cleanup.clear();
|
||||
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const DEFAULT_LIMITS = { connections: 512, perUser: 8, opening: 32 } as const;
|
||||
const OPENING_DEADLINE_MS = 5000;
|
||||
|
||||
export interface RealtimeConnectionReservation {
|
||||
canContinue(): boolean;
|
||||
reserveUser(userId: string): boolean;
|
||||
startStreaming(): boolean;
|
||||
cancelOpening(): void;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** One game API process owns both pending setup and live streams; no waiting queue. */
|
||||
export class RealtimeConnectionAdmission {
|
||||
private readonly limits: { connections: number; perUser: number; opening: number };
|
||||
private total = 0;
|
||||
private opening = 0;
|
||||
private closed = false;
|
||||
private readonly byUser = new Map<string, number>();
|
||||
private readonly pending = new Set<() => void>();
|
||||
|
||||
constructor(limits: Partial<{ connections: number; perUser: number; opening: number }> = {}) {
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
if (Object.values(this.limits).some((value) => !Number.isSafeInteger(value) || value < 1))
|
||||
throw new Error('Invalid realtime connection limits.');
|
||||
}
|
||||
|
||||
begin(onTimeout: () => void): RealtimeConnectionReservation | null {
|
||||
if (this.closed || this.total >= this.limits.connections || this.opening >= this.limits.opening) return null;
|
||||
this.total += 1;
|
||||
this.opening += 1;
|
||||
let userId: string | null = null;
|
||||
let streaming = false;
|
||||
let cancelled = false;
|
||||
let released = false;
|
||||
const deadline = Date.now() + OPENING_DEADLINE_MS;
|
||||
const expire = () => {
|
||||
if (released || streaming || cancelled) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
onTimeout();
|
||||
};
|
||||
const timer = setTimeout(expire, OPENING_DEADLINE_MS);
|
||||
timer.unref();
|
||||
this.pending.add(expire);
|
||||
const canContinue = () => {
|
||||
if (!released && !streaming && !cancelled && Date.now() >= deadline) expire();
|
||||
return !released && !streaming && !cancelled;
|
||||
};
|
||||
return {
|
||||
canContinue,
|
||||
reserveUser: (id) => {
|
||||
if (!canContinue() || userId !== null) return false;
|
||||
const count = this.byUser.get(id) ?? 0;
|
||||
if (count >= this.limits.perUser) return false;
|
||||
this.byUser.set(id, count + 1);
|
||||
userId = id;
|
||||
return true;
|
||||
},
|
||||
startStreaming: () => {
|
||||
if (!canContinue() || userId === null) return false;
|
||||
streaming = true;
|
||||
this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
return true;
|
||||
},
|
||||
cancelOpening: () => {
|
||||
if (released || streaming) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
// Disconnect/timeout does not prove the awaited Redis/SQL operation has settled.
|
||||
},
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.total -= 1;
|
||||
if (!streaming) this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
if (userId !== null) {
|
||||
const count = (this.byUser.get(userId) ?? 1) - 1;
|
||||
if (count === 0) this.byUser.delete(userId);
|
||||
else this.byUser.set(userId, count);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
for (const expire of this.pending) expire();
|
||||
}
|
||||
}
|
||||
@@ -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,16 +232,39 @@ export const boardRouter = router({
|
||||
|
||||
return { id: comment.id };
|
||||
}),
|
||||
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
uploadImage: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dataUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, true);
|
||||
|
||||
try {
|
||||
return await boardImageBudget.run(async () => {
|
||||
const buffer = parseDataUrl(input.dataUrl);
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'PAYLOAD_TOO_LARGE',
|
||||
message: '이미지 용량 제한(1MB)을 초과했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
if (!isRasterImage(buffer))
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
});
|
||||
if ((metadata.pages ?? 1) > 256)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
|
||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
}
|
||||
@@ -249,7 +291,11 @@ export const boardRouter = router({
|
||||
animated: isAnimated || format === 'gif',
|
||||
resize: needsResize,
|
||||
});
|
||||
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
|
||||
if (
|
||||
format === 'webp' &&
|
||||
!needsResize &&
|
||||
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
|
||||
) {
|
||||
outputBuffer = buffer;
|
||||
outputFormat = 'webp';
|
||||
} else {
|
||||
@@ -259,7 +305,10 @@ export const boardRouter = router({
|
||||
}
|
||||
|
||||
if (!ctx.contentImageUpload) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
||||
const uploaded = await ctx.contentImageUpload.upload({
|
||||
@@ -268,7 +317,9 @@ export const boardRouter = router({
|
||||
body: outputBuffer,
|
||||
});
|
||||
|
||||
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
|
||||
const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata();
|
||||
|
||||
return {
|
||||
url: uploaded.publicUrl,
|
||||
@@ -278,5 +329,19 @@ export const boardRouter = router({
|
||||
animated: isAnimated,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageWorkBusyError)
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
if (error instanceof ImageUploadError)
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
+141
-68
@@ -1,8 +1,10 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } 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) {
|
||||
@@ -80,7 +85,12 @@ const resolveAuthFromToken = async (
|
||||
if (!stored) {
|
||||
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 +109,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 +151,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 +178,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) => {
|
||||
@@ -299,17 +333,68 @@ export const createGameApiServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
if (
|
||||
!auth ||
|
||||
auth.profile !== config.profileName ||
|
||||
isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile])
|
||||
)
|
||||
return null;
|
||||
return auth;
|
||||
};
|
||||
|
||||
app.get(config.eventsPath, async (request, reply) => {
|
||||
const query = request.query as { token?: string; scope?: string };
|
||||
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
||||
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
||||
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
|
||||
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
|
||||
|
||||
if (!auth) {
|
||||
if (query.token !== undefined || !tokenFromHeader) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
let expired = false;
|
||||
let disconnected = false;
|
||||
const opening = realtimeAdmission.begin(() => {
|
||||
expired = true;
|
||||
if (!reply.sent && !reply.raw.destroyed) {
|
||||
reply.header('Retry-After', '15');
|
||||
void reply.status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||
}
|
||||
});
|
||||
if (!opening) {
|
||||
await reply.header('Retry-After', '15').status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||
return;
|
||||
}
|
||||
const cancelOpening = () => {
|
||||
disconnected = true;
|
||||
opening.cancelOpening();
|
||||
};
|
||||
request.raw.once('aborted', cancelOpening);
|
||||
reply.raw.once('close', cancelOpening);
|
||||
let transferred = false;
|
||||
try {
|
||||
let auth: GameSessionTokenPayload | null = null;
|
||||
try {
|
||||
auth = await resolveRealtimeAuth(tokenFromHeader);
|
||||
} catch {
|
||||
if (opening.canContinue() && !reply.raw.destroyed)
|
||||
await reply.status(503).send({ ok: false, error: 'authentication_unavailable' });
|
||||
return;
|
||||
}
|
||||
if (!opening.canContinue() || reply.raw.destroyed) return;
|
||||
|
||||
if (!auth || auth.profile !== config.profileName) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
if (!opening.reserveUser(auth.user.id)) {
|
||||
if (opening.canContinue())
|
||||
await reply
|
||||
.header('Retry-After', '15')
|
||||
.status(429)
|
||||
.send({ ok: false, error: 'realtime_connection_limit' });
|
||||
return;
|
||||
}
|
||||
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
@@ -334,6 +419,7 @@ export const createGameApiServer = async () => {
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
|
||||
|
||||
reply.hijack();
|
||||
const requestOrigin = request.headers.origin;
|
||||
@@ -351,28 +437,20 @@ export const createGameApiServer = async () => {
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const sendFrame = (payload: string) => {
|
||||
try {
|
||||
reply.raw.write(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ready',
|
||||
data: '{}',
|
||||
})
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
|
||||
resolveRealtimeAuth(tokenFromHeader)
|
||||
);
|
||||
|
||||
let closed = false;
|
||||
let eventQueue = Promise.resolve();
|
||||
realtimeConnections.add(connection);
|
||||
connection.onClose(() => {
|
||||
realtimeConnections.delete(connection);
|
||||
// A disconnected socket can still own an in-flight Redis/SQL operation.
|
||||
void connection.whenIdle().then(opening.release, opening.release);
|
||||
});
|
||||
transferred = true;
|
||||
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
eventQueue = eventQueue
|
||||
.then(async () => {
|
||||
if (closed) return;
|
||||
connection.enqueue(async (currentAuth) => {
|
||||
const identities = [viewerIdentity];
|
||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||
const nextIdentity = await loadViewerIdentity();
|
||||
@@ -381,69 +459,64 @@ export const createGameApiServer = async () => {
|
||||
}
|
||||
let refreshGrant: string | undefined;
|
||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
|
||||
refreshGrant ??= createRealtimeAccessGrant(
|
||||
currentAuth,
|
||||
config.profileName,
|
||||
config.gameTokenSecret
|
||||
);
|
||||
return refreshGrant;
|
||||
});
|
||||
if (!publicEvent || closed) return;
|
||||
if (!publicEvent || connection.closed) return null;
|
||||
if (refreshGrant) {
|
||||
try {
|
||||
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
|
||||
} catch {
|
||||
// Preserve the invalidation. An unregistered grant safely falls back
|
||||
// to the normal scored refresh path.
|
||||
// An unregistered grant falls back to the scored refresh path.
|
||||
}
|
||||
}
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: publicEvent.type,
|
||||
data: JSON.stringify(publicEvent),
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// A best-effort notification must not affect committed game state.
|
||||
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
|
||||
});
|
||||
});
|
||||
|
||||
connection.onClose(unsubscribe);
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending) return;
|
||||
if (heartbeatPending || connection.closed) return;
|
||||
heartbeatPending = true;
|
||||
void turnEngineStatus
|
||||
.get()
|
||||
.then((turnEngineRunning) => {
|
||||
if (closed) return;
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
connection.enqueue(async () => {
|
||||
try {
|
||||
return formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ turnEngineRunning }),
|
||||
})
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
heartbeatPending = false;
|
||||
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
closed = true;
|
||||
} finally {
|
||||
heartbeatPending = false;
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
connection.onClose(() => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
request.raw.on('close', close);
|
||||
request.raw.on('aborted', close);
|
||||
});
|
||||
request.raw.once('aborted', connection.close);
|
||||
connection.onClose(() => {
|
||||
request.raw.off('aborted', connection.close);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!expired && !disconnected) throw error;
|
||||
} finally {
|
||||
request.raw.off('aborted', cancelOpening);
|
||||
reply.raw.off('close', cancelOpening);
|
||||
if (!transferred) opening.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_request, reply) => {
|
||||
const clock = await loadClockReadiness(postgres.prisma);
|
||||
if (!clock.reconciliationComplete) reply.code(503);
|
||||
const readiness = await loadProfileReadiness(postgres.prisma, config.profileName);
|
||||
if (!readiness.ok) reply.code(503);
|
||||
return {
|
||||
ok: clock.reconciliationComplete,
|
||||
...readiness,
|
||||
profile: config.profileName,
|
||||
postgresPool: postgres.getPoolStats(),
|
||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||
clock,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
@@ -90,3 +91,20 @@ export const loadClockAdminStatus = async (db: DatabaseClient) => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** API 기동과 별개로 데몬의 초기 감사 저장 및 clock 복구가 끝나야 배포 준비 완료다. */
|
||||
export const loadProfileReadiness = async (db: DatabaseClient, profileName: string) => {
|
||||
const [clock, leases] = await Promise.all([
|
||||
loadClockReadiness(db),
|
||||
db.$queryRaw<Array<{ ready: boolean }>>(GamePrisma.sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM turn_daemon_lease
|
||||
WHERE profile = ${profileName}
|
||||
AND clock_ready = TRUE
|
||||
AND lease_until > (clock_timestamp() AT TIME ZONE 'UTC')
|
||||
) AS ready
|
||||
`),
|
||||
]);
|
||||
const daemonReady = leases[0]?.ready === true;
|
||||
return { ok: clock.reconciliationComplete && daemonReady, clock, daemonReady };
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GamePrisma,
|
||||
hashAuditDiplomacyDocument,
|
||||
persistAuditDiplomacyEvents,
|
||||
withPlayAuditSavepoint,
|
||||
projectAuditDocumentState,
|
||||
readTurnRuntimeReady,
|
||||
type AuditDiplomacyEventDraft,
|
||||
@@ -83,6 +84,7 @@ const readCoordinate = async (ctx: GameApiContext) => {
|
||||
};
|
||||
|
||||
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
|
||||
let collectionFailed = false;
|
||||
const changes: {
|
||||
letter: Letter;
|
||||
before: Record<string, unknown> | null;
|
||||
@@ -92,15 +94,21 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
|
||||
return {
|
||||
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
|
||||
if (!ctx.auditInput) return;
|
||||
try {
|
||||
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||
} catch {
|
||||
collectionFailed = true;
|
||||
}
|
||||
},
|
||||
flush: async (): Promise<void> => {
|
||||
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
|
||||
const input = ctx.auditInput;
|
||||
if (!input || !changes.length) return;
|
||||
if (!input || (!changes.length && !collectionFailed)) return;
|
||||
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
|
||||
throw new Error('Play audit diplomacy actor mismatch');
|
||||
const coordinate = await readCoordinate(ctx);
|
||||
await withPlayAuditSavepoint(ctx.db, async (auditDb) => {
|
||||
if (collectionFailed) throw new Error('Audit document collection failed');
|
||||
const coordinate = await readCoordinate({ ...ctx, db: auditDb });
|
||||
if (!coordinate) return;
|
||||
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||
schemaVersion: 1,
|
||||
@@ -133,7 +141,8 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
|
||||
before: change.before,
|
||||
after: change.after,
|
||||
}));
|
||||
await persistAuditDiplomacyEvents(ctx.db, events);
|
||||
await persistAuditDiplomacyEvents(auditDb, events);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||
|
||||
export interface ContentImageUploadResult {
|
||||
publicUrl: string;
|
||||
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
|
||||
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
||||
throw new Error('Invalid content image filename.');
|
||||
}
|
||||
const pathname = `/v1/uploads/content/core2026/${input.filename}`;
|
||||
const expires = String(Math.floor(this.now() / 1000) + 60);
|
||||
const requestId = randomUUID();
|
||||
const digest = createHash('sha256').update(input.body).digest('hex');
|
||||
const signature = createHmac('sha256', this.secret)
|
||||
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
|
||||
.digest('hex');
|
||||
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
'x-image-client': 'core2026',
|
||||
'x-image-expires': expires,
|
||||
'x-image-request-id': requestId,
|
||||
'x-image-signature': signature,
|
||||
await performSignedImageUpload(
|
||||
{
|
||||
...input,
|
||||
kind: 'content',
|
||||
baseUrl: this.baseUrl,
|
||||
secret: this.secret,
|
||||
},
|
||||
body: new Uint8Array(input.body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
||||
}
|
||||
const expectedPath = `uploads/core2026/${input.filename}`;
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) {
|
||||
throw new Error('Image repository returned an unexpected content path.');
|
||||
}
|
||||
this.fetchImpl,
|
||||
this.now
|
||||
);
|
||||
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +21,7 @@ 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) {
|
||||
|
||||
@@ -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 = [
|
||||
// Ref che_선양.vue는 후보에 (통/무/지)만 보인다.
|
||||
const details = (
|
||||
action === 'che_선양'
|
||||
? []
|
||||
: [
|
||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
]
|
||||
).filter((value): value is string => Boolean(value));
|
||||
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
||||
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
||||
const assignmentDetails =
|
||||
action === 'che_발령'
|
||||
? [
|
||||
[
|
||||
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
||||
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
||||
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
statsText(entry),
|
||||
[
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
@@ -104,6 +129,9 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: undefined;
|
||||
// Ref ProcessGeneralAmount·ProcessGeneral은 같은 국가 후보에 (통/무/지)를 함께 보인다.
|
||||
const stats = statsText(entry);
|
||||
if (stats && action !== 'che_발령') details.push(stats);
|
||||
if (isTroopExit) {
|
||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||
}
|
||||
@@ -125,33 +153,48 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
npcState: entry.npcState,
|
||||
};
|
||||
};
|
||||
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
||||
options.generals.filter(predicate).map((entry) => toOption(entry));
|
||||
// Ref che_등용·che_장수대상임관 exportJSVars()는 타국·재야 장수의
|
||||
// no,name,nation,officer_level,npc,leadership,strength,intel만 내보낸다.
|
||||
// 명령표는 모든 장수에게 전달되므로 금·쌀·병력·훈련·사기·부대·위치 도시를 싣지 않는다.
|
||||
const toPublicOption = (entry: GeneralTargetSource): TurnCommandOption => {
|
||||
const nationName = options.nationNames.get(entry.nationId) ?? null;
|
||||
return {
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationName ?? '재야'})`,
|
||||
targetNames: { name: entry.name, nationName },
|
||||
description: statsText(entry),
|
||||
npcState: entry.npcState,
|
||||
nationId: entry.nationId,
|
||||
};
|
||||
};
|
||||
const projectPublic = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
||||
options.generals.filter(predicate).map(toPublicOption);
|
||||
// 재야는 국가가 아니므로 같은 국가 명령 후보가 없다. 해당 명령도 NotBeNeutral로 막힌다.
|
||||
const isSameNation = (entry: GeneralTargetSource): boolean =>
|
||||
options.actorNationId !== 0 && entry.nationId === options.actorNationId;
|
||||
|
||||
const generalTargets: Record<string, TurnCommandOption[]> = {};
|
||||
for (const action of SAME_NATION_GENERAL_COMMANDS) {
|
||||
generalTargets[action] = options.generals
|
||||
.filter((entry) => entry.nationId === options.actorNationId)
|
||||
.map((entry) => toOption(entry, action));
|
||||
generalTargets[action] = options.generals.filter(isSameNation).map((entry) => toOption(entry, action));
|
||||
}
|
||||
for (const action of SAME_NATION_NATION_COMMANDS) {
|
||||
generalTargets[action] = options.generals
|
||||
.filter((entry) => entry.nationId === options.actorNationId)
|
||||
.filter(isSameNation)
|
||||
.map((entry) => toOption(entry, action))
|
||||
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
|
||||
}
|
||||
|
||||
generalTargets.che_선양 = project(
|
||||
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
|
||||
);
|
||||
generalTargets.che_등용 = project(
|
||||
generalTargets.che_선양 = options.generals
|
||||
.filter((entry) => isSameNation(entry) && entry.id !== options.actorId)
|
||||
.map((entry) => toOption(entry, 'che_선양'));
|
||||
generalTargets.che_등용 = projectPublic(
|
||||
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
|
||||
);
|
||||
generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId);
|
||||
generalTargets.che_장수대상임관 = projectPublic((entry) => entry.id !== options.actorId);
|
||||
|
||||
return {
|
||||
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
|
||||
generals: project((entry) => entry.npcState < 2),
|
||||
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다. 타국 장수가 섞이므로 공개 필드만 둔다.
|
||||
generals: projectPublic((entry) => entry.npcState < 2),
|
||||
generalTargets,
|
||||
};
|
||||
};
|
||||
@@ -169,14 +212,63 @@ const NATION_TARGET_COMMANDS = [
|
||||
'che_선전포고',
|
||||
'che_종전제의',
|
||||
'che_불가침파기제의',
|
||||
'che_피장파장',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
] as const;
|
||||
|
||||
const nationAvailability = (
|
||||
action: (typeof NATION_TARGET_COMMANDS)[number],
|
||||
/** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */
|
||||
export const COUNTER_STRATEGY_COMMAND_KEYS = [
|
||||
'che_필사즉생',
|
||||
'che_백성동원',
|
||||
'che_수몰',
|
||||
'che_허보',
|
||||
'che_의병모집',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
] as const;
|
||||
|
||||
/** Ref che_임관 exportJSVars()의 notAvailable: AllowJoinDestNation의 대상 국가 조건. */
|
||||
const joinAvailability = (
|
||||
join: NationJoinContext,
|
||||
actorNationId: number,
|
||||
target: NationTargetSource
|
||||
): { available: boolean; reason: string } => {
|
||||
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
||||
const gennum = target.gennum ?? target.generalCount;
|
||||
if (join.relYear < join.openingPartYear && join.initialNationGenLimit > 0 && gennum >= join.initialNationGenLimit) {
|
||||
return { available: false, reason: '임관이 제한되고 있습니다.' };
|
||||
}
|
||||
if (target.blockScout) return { available: false, reason: '임관이 금지되어 있습니다.' };
|
||||
if (join.actorNpcState < 2 && target.name.startsWith('ⓤ')) {
|
||||
return { available: false, reason: '유저장은 태수국에 임관할 수 없습니다.' };
|
||||
}
|
||||
if (join.actorNpcState !== 9 && target.name.startsWith('ⓞ')) {
|
||||
return { available: false, reason: '이민족 국가에 임관할 수 없습니다.' };
|
||||
}
|
||||
return { available: true, reason: '현재 임관 가능' };
|
||||
};
|
||||
|
||||
const nationAvailability = (
|
||||
action: (typeof NATION_TARGET_COMMANDS)[number],
|
||||
actorNationId: number,
|
||||
target: NationTargetSource,
|
||||
strategyAvailable: boolean
|
||||
): { available: boolean; reason: string } => {
|
||||
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
||||
// Ref 피장파장은 쓸 수 있는 전략이 하나도 없으면 모든 국가를 불가로 표시한다.
|
||||
if (action === 'che_피장파장' && !strategyAvailable) {
|
||||
return { available: false, reason: '사용할 수 있는 전략이 없습니다.' };
|
||||
}
|
||||
if (action === 'che_피장파장' || action === 'che_이호경식') {
|
||||
const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
|
||||
return { available, reason: available ? '현재 발동 가능' : '선포, 전쟁중인 상대국에게만 가능합니다.' };
|
||||
}
|
||||
if (action === 'che_급습') {
|
||||
// Ref AllowDiplomacyWithTerm(1, 12): 선포 상태이고 남은 기간이 12개월 이상.
|
||||
const available = target.diplomacyState === DIPLOMACY_STATE.DECLARATION && target.diplomacyTerm >= 12;
|
||||
return { available, reason: available ? '현재 발동 가능' : '선포 12개월 이상인 상대국에만 가능합니다.' };
|
||||
}
|
||||
if (action === 'che_물자원조') {
|
||||
return target.diplomacyRestricted
|
||||
? { available: false, reason: '상대국이 외교제한 중입니다.' }
|
||||
@@ -205,7 +297,12 @@ const nationAvailability = (
|
||||
export const buildRefNationTargetOptions = (options: {
|
||||
actorNationId: number;
|
||||
nations: readonly NationTargetSource[];
|
||||
/** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */
|
||||
join?: NationJoinContext;
|
||||
/** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */
|
||||
strategyAvailable?: boolean;
|
||||
}): RefNationTargetOptions => {
|
||||
const strategyAvailable = options.strategyAvailable ?? true;
|
||||
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
@@ -217,7 +314,7 @@ export const buildRefNationTargetOptions = (options: {
|
||||
for (const action of NATION_TARGET_COMMANDS) {
|
||||
nationTargets[action] = options.nations
|
||||
.map((entry) => {
|
||||
const availability = nationAvailability(action, options.actorNationId, entry);
|
||||
const availability = nationAvailability(action, options.actorNationId, entry, strategyAvailable);
|
||||
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
|
||||
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
|
||||
return {
|
||||
@@ -238,7 +335,31 @@ export const buildRefNationTargetOptions = (options: {
|
||||
)
|
||||
.map(({ power: _power, ...entry }) => entry);
|
||||
}
|
||||
return { nations: baseOptions, nationTargets };
|
||||
const join = options.join;
|
||||
if (join) {
|
||||
// Ref 임관 목록은 nation 표 순서 그대로이며 불가 국가도 남긴다. 재야(0)는 국가가 아니다.
|
||||
nationTargets.che_임관 = options.nations.flatMap((entry, index) => {
|
||||
if (entry.id <= 0) return [];
|
||||
const availability = joinAvailability(join, options.actorNationId, entry);
|
||||
return [
|
||||
{
|
||||
...baseOptions[index]!,
|
||||
availableNow: availability.available,
|
||||
description: `${availability.reason} · ${baseOptions[index]!.description}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
// Ref che_임관·che_장수대상임관은 모든 국가의 권유문(scout_msg)을 함께 내보낸다.
|
||||
const nationScoutMessages = options.nations
|
||||
.filter((entry) => entry.id > 0)
|
||||
.map((entry) => ({
|
||||
nationId: entry.id,
|
||||
name: entry.name,
|
||||
color: entry.color,
|
||||
message: entry.scoutMessage ?? '',
|
||||
}));
|
||||
return { nations: baseOptions, nationTargets, nationScoutMessages };
|
||||
};
|
||||
|
||||
const RESOURCE_ACTION_GUIDE = [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SystemClock } from '@sammo-ts/common';
|
||||
import {
|
||||
createDatabaseTurnHooks,
|
||||
DatabaseTurnDaemonCommandQueue,
|
||||
DatabaseTurnDaemonLease,
|
||||
EngineStateManager,
|
||||
InMemoryTurnStateStore,
|
||||
InMemoryTurnWorld,
|
||||
@@ -695,6 +696,10 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
|
||||
const redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await redisConnector.connect();
|
||||
// 독립 worker는 활성 daemon의 clock_ready fence가 있을 때만 GAME_TIME을 진행한다.
|
||||
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile: profileName });
|
||||
await lease.acquire();
|
||||
await lease.markClockReady();
|
||||
const keys = buildAuctionTimerKeys(profileName);
|
||||
const abortController = new AbortController();
|
||||
const worker = runAuctionWorker({ signal: abortController.signal });
|
||||
@@ -725,6 +730,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
} finally {
|
||||
abortController.abort();
|
||||
await worker;
|
||||
await lease.close();
|
||||
await connector.prisma.turnDaemonLease.delete({ where: { profile: profileName } });
|
||||
await redisConnector.client.del([keys.timerKey, keys.historyKey]);
|
||||
await redisConnector.disconnect();
|
||||
await connector.prisma.errorLog.deleteMany({
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||
|
||||
const payload = (): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
sessionId: 'test-session',
|
||||
profile: 'che:default',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
user: {
|
||||
id: 'test-user',
|
||||
username: 'test',
|
||||
displayName: 'test',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
const fixture = (resolve: () => Promise<GameSessionTokenPayload | null>, auth = payload()) => {
|
||||
const response = new PassThrough();
|
||||
const frames: string[] = [];
|
||||
response.on('data', (chunk) => frames.push(String(chunk)));
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve);
|
||||
return { response, frames, connection };
|
||||
};
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('authenticated realtime connection', () => {
|
||||
it('does not forward an in-flight result after the token is revoked during its work', async () => {
|
||||
const auth = payload();
|
||||
let current: GameSessionTokenPayload | null = auth;
|
||||
const { connection, frames } = fixture(async () => current, auth);
|
||||
connection.enqueue(async () => {
|
||||
current = null;
|
||||
return 'private invalidation';
|
||||
});
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth);
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('fails closed when authentication storage fails', async () => {
|
||||
const { connection, frames } = fixture(async () => {
|
||||
throw new Error('redis unavailable');
|
||||
});
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends an idle expired stream and releases subscriptions only once', async () => {
|
||||
vi.useFakeTimers();
|
||||
const auth = payload();
|
||||
const { connection } = fixture(async () => auth, auth);
|
||||
const cleanup = vi.fn();
|
||||
connection.onClose(cleanup);
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
expect(connection.closed).toBe(true);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
connection.close();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('bounds queued work while an earlier event is blocked', async () => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => auth, auth);
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
connection.enqueue(async () => {
|
||||
await waiting;
|
||||
return 'private invalidation';
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation');
|
||||
expect(connection.closed).toBe(true);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends a slow socket instead of retaining further frames', async () => {
|
||||
const auth = payload();
|
||||
const { connection, response } = fixture(async () => auth, auth);
|
||||
response.write = vi.fn(() => false);
|
||||
connection.enqueue(async () => 'event: ping\ndata: {}\n\n');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(response.writableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import fastify from 'fastify';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js';
|
||||
|
||||
const internalFailure = () => {
|
||||
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
|
||||
};
|
||||
const badInput = () => {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '잘못된 입력입니다.',
|
||||
cause: new Error('synthetic-private-cause'),
|
||||
});
|
||||
};
|
||||
|
||||
describe('public HTTP error privacy', () => {
|
||||
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
|
||||
const app = fastify();
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/game',
|
||||
trpcOptions: {
|
||||
router: gameRouter({
|
||||
internal: sessionActivityProcedure.query(internalFailure),
|
||||
invalid: sessionActivityProcedure.query(badInput),
|
||||
}),
|
||||
createContext: () => ({}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
for (const prefix of ['game']) {
|
||||
for (const procedure of ['internal', 'invalid']) {
|
||||
const response = await fetch(`${address}/${prefix}/${procedure}`);
|
||||
const body = (await response.json()) as {
|
||||
error: { message: string; data: { code: string; stack?: string } };
|
||||
};
|
||||
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
|
||||
expect(body.error.message).toBe(
|
||||
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
|
||||
);
|
||||
expect(body.error.data.stack).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('synthetic-');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,13 @@ const auth: GameSessionTokenPayload = {
|
||||
};
|
||||
|
||||
const buildContext = (
|
||||
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,
|
||||
@@ -30,14 +36,19 @@ const buildContext = (
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 7,
|
||||
userId: 'owner',
|
||||
officerLevel: options.officerLevel ?? 1,
|
||||
nationId: 2,
|
||||
})),
|
||||
findMany: vi.fn(async () => [
|
||||
findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
|
||||
input.where?.meta
|
||||
? (options.officeRows ?? [])
|
||||
: [
|
||||
{ id: 7, name: '유비', nationId: 2 },
|
||||
{ id: 8, name: '관우', nationId: 2 },
|
||||
{ id: 9, name: '조조', nationId: 3 },
|
||||
{ id: 10, name: '재야장수', nationId: 0 },
|
||||
]),
|
||||
]
|
||||
),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
@@ -109,6 +120,7 @@ describe('general.getFrontStatus', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: 'che_260819_front',
|
||||
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
|
||||
onlineUserCount: 4,
|
||||
onlineNations: '【촉】, 【위】',
|
||||
onlineGenerals: '유비, 관우',
|
||||
@@ -173,3 +185,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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -172,7 +174,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 +418,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);
|
||||
}
|
||||
@@ -742,6 +744,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 +822,28 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
|
||||
const input = { dataUrl: 'data:image/png;base64,AA==' };
|
||||
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
|
||||
expect(unauthenticated.response.status).toBe(401);
|
||||
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
|
||||
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
|
||||
expect(denied.response.status).toBe(403);
|
||||
const officer = await createAccessToken('image-officer', {});
|
||||
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
|
||||
expect(malformed.response.status).toBe(400);
|
||||
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const oversized = await requestTrpc('board.uploadImage', {
|
||||
method: 'POST',
|
||||
accessToken: officer,
|
||||
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
|
||||
});
|
||||
expect(oversized.response.status).toBe(400);
|
||||
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
|
||||
});
|
||||
|
||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||
const accessToken = await createAccessToken('json-query-body', {});
|
||||
const general = await requestTrpc('general.me', {
|
||||
@@ -2317,9 +2343,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 +2420,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 +2947,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 +3340,39 @@ integration('game API security over HTTP transport', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.playAuditNation.update({
|
||||
where: { sampleId_nationId: { sampleId: `${seasonId}:190:6`, nationId: ownerNationId } },
|
||||
data: {
|
||||
data: {
|
||||
...asRecord(finalNation.data),
|
||||
populations: {
|
||||
human: { ...population, count: 2 },
|
||||
npc: { ...population, count: 1 },
|
||||
troopNpc: population,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.playAuditGeneral.createMany({
|
||||
data: [1, 2].map((id) => ({
|
||||
sampleId: `${seasonId}:190:6`,
|
||||
generalId: id,
|
||||
nationId: ownerNationId,
|
||||
cityId: 0,
|
||||
npcState: 0,
|
||||
data: { crew: id * 1200 },
|
||||
})),
|
||||
});
|
||||
await db.playAuditGeneral.create({
|
||||
data: {
|
||||
sampleId: `${seasonId}:190:6`,
|
||||
generalId: 3,
|
||||
nationId: ownerNationId,
|
||||
cityId: 0,
|
||||
npcState: 2,
|
||||
data: { crew: 'invalid' },
|
||||
},
|
||||
});
|
||||
const series = await get('nationSeries', admin, {
|
||||
nationId: ownerNationId,
|
||||
from: { year: 190, month: 1 },
|
||||
@@ -3134,7 +3388,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 +3423,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 +3598,362 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const openRealtime = async (accessToken: string) => {
|
||||
const abort = new AbortController();
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
signal: abort.signal,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = response.body!.getReader();
|
||||
const ready = await reader.read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
return { abort, reader };
|
||||
};
|
||||
|
||||
it('SSE limits simultaneous openings for one authenticated user', async () => {
|
||||
const actor = `security-http-sse-concurrent-${process.pid}`;
|
||||
const token = await createAccessToken('sse-concurrent', {}, actor);
|
||||
const aborts = Array.from({ length: 10 }, () => new AbortController());
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
aborts.map((abort) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: abort.signal,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(responses.filter((response) => response.status === 200)).toHaveLength(8);
|
||||
expect(responses.filter((response) => response.status === 429)).toHaveLength(2);
|
||||
for (const response of responses.filter((item) => item.status === 429)) {
|
||||
expect(response.headers.get('retry-after')).toBe('15');
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
}
|
||||
} finally {
|
||||
for (const abort of aborts) abort.abort();
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE limits the same user across tokens and scopes and recovers after disconnect', async () => {
|
||||
const actor = `security-http-sse-shared-${process.pid}`;
|
||||
const token = await createAccessToken('sse-shared-a', {}, actor);
|
||||
const otherToken = await createAccessToken('sse-shared-b', {}, actor);
|
||||
const otherActorToken = await createAccessToken('sse-independent', {}, `${actor}-other`);
|
||||
const streams: Awaited<ReturnType<typeof openRealtime>>[] = [];
|
||||
const replacementAbort = new AbortController();
|
||||
try {
|
||||
for (let index = 0; index < 8; index += 1) streams.push(await openRealtime(token));
|
||||
const rejected = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
});
|
||||
expect(rejected.status).toBe(429);
|
||||
expect(await rejected.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
streams.push(await openRealtime(otherActorToken));
|
||||
await streams[0]!.reader.cancel();
|
||||
streams[0]!.abort.abort();
|
||||
let replacement: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
signal: replacementAbort.signal,
|
||||
});
|
||||
if (response.status === 200) replacement = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const ready = await replacement!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
} finally {
|
||||
replacementAbort.abort();
|
||||
for (const stream of streams) stream.abort.abort();
|
||||
await Promise.all([token, otherToken, otherActorToken].map((value) => accessTokenStore.revoke(value)));
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE bounds pending openings after disconnect and backend deadline until real SQL work settles', async () => {
|
||||
if (!/^[A-Za-z0-9_]+$/.test(profileId)) throw new Error('Invalid isolated schema identifier');
|
||||
const actor = `security-http-sse-pending-${process.pid}`;
|
||||
const tokens = await Promise.all(
|
||||
Array.from({ length: 33 }, (_, index) => createAccessToken(`sse-pending-${index}`, {}, `${actor}-${index}`))
|
||||
);
|
||||
const aborts = Array.from({ length: 33 }, () => new AbortController());
|
||||
let unlock!: () => void;
|
||||
const release = new Promise<void>((resolve) => {
|
||||
unlock = resolve;
|
||||
});
|
||||
let ready!: (tx: GamePrisma.TransactionClient) => void;
|
||||
let lockError!: (error: unknown) => void;
|
||||
const locked = new Promise<GamePrisma.TransactionClient>((resolve, reject) => {
|
||||
ready = resolve;
|
||||
lockError = reject;
|
||||
});
|
||||
// Only the guarded, dedicated fixture schema is locked. No runtime or Ref table is touched.
|
||||
const locking = db.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$executeRawUnsafe(`LOCK TABLE "${profileId}"."general" IN ACCESS EXCLUSIVE MODE`);
|
||||
ready(tx);
|
||||
await release;
|
||||
},
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
void locking.catch(lockError);
|
||||
try {
|
||||
const tx = await locked;
|
||||
const first = fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[0]!}` },
|
||||
signal: aborts[0]!.signal,
|
||||
}).then(
|
||||
() => false,
|
||||
() => true
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const rows = await tx.$queryRaw<Array<{ count: number }>>`
|
||||
SELECT COUNT(*)::int AS count FROM pg_locks
|
||||
WHERE NOT granted AND relation = ${`${profileId}.general`}::regclass
|
||||
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
`;
|
||||
return rows[0]!.count;
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const startedAt = Date.now();
|
||||
const waiting = tokens.slice(1, 32).map((token, index) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: aborts[index + 1]!.signal,
|
||||
})
|
||||
);
|
||||
aborts[0]!.abort();
|
||||
expect(await first).toBe(true);
|
||||
const responses = await Promise.all(waiting);
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4500);
|
||||
expect(Date.now() - startedAt).toBeLessThan(8500);
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_unavailable' });
|
||||
}
|
||||
const refusedAt = Date.now();
|
||||
const refused = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
expect(refused.status).toBe(503);
|
||||
expect(refused.headers.get('retry-after')).toBe('15');
|
||||
expect(Date.now() - refusedAt).toBeLessThan(2000);
|
||||
await refused.arrayBuffer();
|
||||
unlock();
|
||||
await locking;
|
||||
let recovered: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
if (response.status === 200) recovered = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const frame = await recovered!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(frame.value)).toContain('event: ready');
|
||||
} finally {
|
||||
unlock();
|
||||
for (const abort of aborts) abort.abort();
|
||||
await locking;
|
||||
await Promise.all(tokens.map((token) => accessTokenStore.revoke(token)));
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
|
||||
const token = await createAccessToken('sse-query', {});
|
||||
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
|
||||
expect(queryOnly.status).toBe(401);
|
||||
const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' });
|
||||
if (!foreign) throw new Error('SSE foreign token fixture failed');
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const stream = await openRealtime(token);
|
||||
stream.abort.abort();
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{ label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{
|
||||
label: 'profile game restriction',
|
||||
sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } },
|
||||
},
|
||||
])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => {
|
||||
const token = await createAccessToken(`sse-${label}`, sanctions);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes on idle expiry without forwarding another frame', async () => {
|
||||
const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() };
|
||||
const created = await accessTokenStore.create(auth);
|
||||
if (!created) throw new Error('SSE expiry token fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => {
|
||||
const accessToken = await createAccessToken('sse-revoke', {});
|
||||
const stream = await openRealtime(accessToken);
|
||||
const channel = server!.config.profileName;
|
||||
try {
|
||||
const event = JSON.stringify({ type: 'tournamentChanged' });
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
const valid = await stream.reader.read();
|
||||
expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant');
|
||||
expect(await accessTokenStore.revoke(accessToken)).toBe(true);
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => {
|
||||
const sseUser = `security-http-sse-flush-${process.pid}`;
|
||||
const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser));
|
||||
if (!created) throw new Error('SSE flush fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
await redis!.client.publish(
|
||||
`${redisPrefix}:flush`,
|
||||
JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
|
||||
const actor = `security-http-durable-missed-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missed', {}, actor);
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('retains persisted revocation state across an actual API restart', async () => {
|
||||
const actor = `security-http-durable-restart-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-restart', {}, actor);
|
||||
try {
|
||||
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await server!.app.close();
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
|
||||
method: 'POST',
|
||||
input: { gatewayToken },
|
||||
});
|
||||
expect(exchanged.response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
|
||||
const actor = `security-http-durable-sse-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-sse', {}, actor);
|
||||
const stream = await openRealtime(accessToken);
|
||||
try {
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await redis!.client.publish(
|
||||
buildGameEventChannel(profileName),
|
||||
JSON.stringify({ type: 'tournamentChanged' })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
|
||||
const actor = `security-http-durable-cutoff-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.set(key, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const fresh = await accessTokenStore.create({
|
||||
...buildPayload('fresh-cutoff', {}, actor),
|
||||
issuedAt: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
expect(fresh).not.toBeNull();
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
|
||||
200
|
||||
);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(fresh!.accessToken);
|
||||
}
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
|
||||
const actor = `security-http-durable-missing-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missing', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.del(key);
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
|
||||
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
expect(sse.status).toBe(503);
|
||||
await expect(createGameApiServer()).rejects.toThrow('startup refused');
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
// Flush invalidates every token issued before the user watermark. Keep it
|
||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||
// by the transport authorization matrix above.
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,7 +33,11 @@ const readFlows = (world: InMemoryTurnWorld): MonthlyFlows => {
|
||||
return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries };
|
||||
};
|
||||
|
||||
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void => {
|
||||
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordAuditSettlement',
|
||||
() => {
|
||||
if (typeof world.getState().meta.serverId !== 'string') return;
|
||||
const flows = readFlows(world);
|
||||
const key = `${settlement.nationId}:${settlement.resource}`;
|
||||
@@ -44,12 +49,18 @@ export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: Audi
|
||||
};
|
||||
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
|
||||
world.updateWorldMeta({ playAuditFlows: flows });
|
||||
};
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
export const queueAuditMonth = (
|
||||
world: InMemoryTurnWorld,
|
||||
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
|
||||
): void => {
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'queueAuditMonth',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
|
||||
@@ -71,10 +82,16 @@ export const queueAuditMonth = (
|
||||
kind,
|
||||
settlementsComplete: flows.complete,
|
||||
});
|
||||
};
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
|
||||
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
|
||||
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'initializeAuditCollection',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
@@ -111,7 +128,9 @@ export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt =
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
|
||||
beforeMonthChanged: (context) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from './bestEffort.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnDiplomacy } from '../turn/types.js';
|
||||
@@ -7,7 +8,11 @@ export const recordMonthlyAuditDiplomacy = (
|
||||
world: InMemoryTurnWorld,
|
||||
before: readonly TurnDiplomacy[],
|
||||
afterByKey: ReadonlyMap<string, TurnDiplomacy>
|
||||
): void => {
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordMonthlyAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
@@ -46,7 +51,9 @@ export const recordMonthlyAuditDiplomacy = (
|
||||
after: nextState,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
export interface AuditDiplomacyAction {
|
||||
actionKey: string;
|
||||
@@ -68,7 +75,11 @@ export const recordTurnAuditDiplomacy = (
|
||||
after: TurnDiplomacy,
|
||||
action: AuditDiplomacyAction,
|
||||
turn: { generalId: number; tick: number; ordinal: number }
|
||||
): void => {
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordTurnAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
@@ -95,14 +106,25 @@ export const recordTurnAuditDiplomacy = (
|
||||
ordinal: turn.ordinal,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: { ...action.actor, actionKey: action.actionKey, kind: action.kind, actionOrdinal: action.actionOrdinal },
|
||||
actor: {
|
||||
...action.actor,
|
||||
actionKey: action.actionKey,
|
||||
kind: action.kind,
|
||||
actionOrdinal: action.actionOrdinal,
|
||||
},
|
||||
before: previousState,
|
||||
after: nextState,
|
||||
});
|
||||
};
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */
|
||||
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
|
||||
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'initializeAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
@@ -177,7 +199,9 @@ export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt =
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
|
||||
export const recordNationAuditDiplomacy = (
|
||||
@@ -186,7 +210,11 @@ export const recordNationAuditDiplomacy = (
|
||||
relations: readonly TurnDiplomacy[],
|
||||
operation: 'CREATED' | 'REMOVED',
|
||||
observedTick?: number
|
||||
): void => {
|
||||
): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'recordNationAuditDiplomacy',
|
||||
() => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
@@ -229,4 +257,6 @@ export const recordNationAuditDiplomacy = (
|
||||
after: operation === 'CREATED' ? value : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from './bestEffort.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { Nation } from '@sammo-ts/logic';
|
||||
@@ -70,7 +71,11 @@ export const recordAuditPolicyChange = (options: {
|
||||
actor?: TurnGeneral;
|
||||
permission?: number;
|
||||
requestId?: string;
|
||||
}): { _playAuditPolicy?: Record<string, PolicyHead> } => {
|
||||
}): { _playAuditPolicy?: Record<string, PolicyHead> } =>
|
||||
collectPlayAudit(
|
||||
options.world,
|
||||
'recordAuditPolicyChange',
|
||||
() => {
|
||||
const { world, nation, area } = options;
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
@@ -134,9 +139,15 @@ export const recordAuditPolicyChange = (options: {
|
||||
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
|
||||
if (!head) throw new Error('Play audit policy baseline missing');
|
||||
return { _playAuditPolicy: { ...heads, [area]: head } };
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void => {
|
||||
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void =>
|
||||
collectPlayAudit(
|
||||
world,
|
||||
'initializeNationAuditPolicies',
|
||||
() => {
|
||||
let nation = world.getNationById(nationId);
|
||||
if (!nation) return;
|
||||
for (const area of AUDIT_POLICY_AREAS) {
|
||||
@@ -148,7 +159,9 @@ export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId
|
||||
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
|
||||
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
|
||||
import { auditPolicyHash, type PendingAuditPolicy } from './policy.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { AUDIT_POLICY_AREAS, auditPolicyHash, type PendingAuditPolicy } from './policy.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
|
||||
export const persistAuditPolicies = async (
|
||||
tx: GamePrisma.TransactionClient,
|
||||
@@ -43,3 +45,48 @@ export const persistAuditPolicies = async (
|
||||
throw new Error('Play audit policy replay payload conflict');
|
||||
}
|
||||
};
|
||||
|
||||
/** 원장은 그대로 두고, 구버전 NPC 생성이 유실한 head projection만 복구한다. */
|
||||
export const restoreMissingAuditPolicyHeads = async (
|
||||
tx: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<boolean> => {
|
||||
const serverId = world.getState().meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
const missing = world.listNations().flatMap((nation) => {
|
||||
const heads = asRecord(nation.meta._playAuditPolicy);
|
||||
return AUDIT_POLICY_AREAS.filter((area) => heads[area] === undefined).map(
|
||||
(area) => GamePrisma.sql`(nation_id = ${nation.id} AND area = ${area})`
|
||||
);
|
||||
});
|
||||
if (!missing.length) return false;
|
||||
// DISTINCT ON으로 각 국가/영역의 마지막 revision만 읽는다. 과거 기수는 복구하지 않는다.
|
||||
const rows = await tx.$queryRaw<
|
||||
{ id: string; nationId: number; area: string; revision: number; after: InputJsonValue }[]
|
||||
>(GamePrisma.sql`
|
||||
SELECT DISTINCT ON (nation_id, area)
|
||||
id, nation_id AS "nationId", area, revision, "after"
|
||||
FROM play_audit_policy
|
||||
WHERE server_id = ${serverId} AND (${GamePrisma.join(missing, ' OR ')})
|
||||
ORDER BY nation_id, area, revision DESC
|
||||
`);
|
||||
for (const row of rows) {
|
||||
if (row.revision < 1 || row.id !== auditPolicyHash([serverId, row.nationId, row.area, row.revision])) {
|
||||
throw new Error('Invalid persisted play audit policy head');
|
||||
}
|
||||
const nation = world.getNationById(row.nationId)!;
|
||||
const heads = nation.meta._playAuditPolicy;
|
||||
world.updateNation(nation.id, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
_playAuditPolicy: {
|
||||
...(heads && typeof heads === 'object' && !Array.isArray(heads) ? heads : {}),
|
||||
[row.area]: { id: row.id, revision: row.revision, hash: auditPolicyHash(row.after), serverId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// 실제 정책이 원장 after와 다르면 후속 initializeAuditPolicies가 OBSERVED_GAP을 남긴다.
|
||||
// 이 메타데이터와 새 이력은 기존 lease/fencing을 거친 startup flush에서 함께 commit된다.
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AuditPopulation = 'human' | 'npc' | 'troopNpc';
|
||||
|
||||
export interface AuditPopulationSummary {
|
||||
count: number;
|
||||
crew: number;
|
||||
gold: number;
|
||||
rice: number;
|
||||
dex: AuditDex;
|
||||
@@ -44,6 +45,7 @@ export interface AuditNationSnapshot {
|
||||
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
|
||||
const emptyPopulation = (): AuditPopulationSummary => ({
|
||||
count: 0,
|
||||
crew: 0,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
dex: emptyDex(),
|
||||
@@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: {
|
||||
const population = nations.get(general.nationId)?.populations[projected.population];
|
||||
if (!population) continue;
|
||||
population.count++;
|
||||
population.crew += projected.crew;
|
||||
population.gold += projected.gold;
|
||||
population.rice += projected.rice;
|
||||
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
asRecord,
|
||||
GAME_TICKS_PER_TURN,
|
||||
OFFICE_REQUEST_KEY,
|
||||
officeRequestQuarter,
|
||||
readOfficeRequest,
|
||||
type OfficeRequest,
|
||||
type TurnDaemonCommand,
|
||||
type TurnDaemonCommandResult,
|
||||
} from '@sammo-ts/common';
|
||||
import {
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
resolveOfficeRequestEligibility,
|
||||
type OfficeCandidate,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
const candidate = (general: TurnGeneral): OfficeCandidate => ({
|
||||
...general,
|
||||
strength: general.stats.strength,
|
||||
intelligence: general.stats.intelligence,
|
||||
});
|
||||
const incumbentFor = (world: InMemoryTurnWorld, cityId: number, level: number): TurnGeneral | undefined =>
|
||||
world
|
||||
.listGenerals()
|
||||
.find((g) => g.officerLevel === level && Number(g.meta.officerCity ?? g.meta.officer_city ?? 0) === cityId);
|
||||
const eligibility = (world: InMemoryTurnWorld, general: TurnGeneral, cityId: number, level: 2 | 3 | 4) => {
|
||||
const incumbent = incumbentFor(world, cityId, level);
|
||||
return resolveOfficeRequestEligibility({
|
||||
applicant: candidate(general),
|
||||
city: world.getCityById(cityId) ?? null,
|
||||
level,
|
||||
incumbent: incumbent ? candidate(incumbent) : null,
|
||||
nationMeta: world.getNationById(general.nationId)?.meta,
|
||||
chiefStatMin: world.getScenarioConfig().stat.chiefMin,
|
||||
});
|
||||
};
|
||||
const save = (world: InMemoryTurnWorld, generalId: number, request: OfficeRequest) => {
|
||||
const general = world.getGeneralById(generalId);
|
||||
if (general) world.updateGeneral(generalId, { meta: { ...general.meta, [OFFICE_REQUEST_KEY]: { ...request } } });
|
||||
};
|
||||
const finish = (world: InMemoryTurnWorld, request: OfficeRequest, status: OfficeRequest['status'], reason: string) => {
|
||||
save(world, request.generalId, { ...request, status, resultReason: reason });
|
||||
const general = world.getGeneralById(request.generalId);
|
||||
if (!general) return;
|
||||
const city = world.getCityById(request.cityId);
|
||||
const title = { 2: '종사', 3: '군사', 4: '태수' }[request.officerLevel];
|
||||
const text = `${general.name} · ${city?.name ?? '도시'} ${title} 자원: ${reason}`;
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: general.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
text,
|
||||
});
|
||||
world.pushLog({
|
||||
scope: LogScope.NATION,
|
||||
nationId: request.nationId,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.PLAIN,
|
||||
text,
|
||||
});
|
||||
};
|
||||
const invalidReason = (world: InMemoryTurnWorld, request: OfficeRequest): string | null => {
|
||||
const general = world.getGeneralById(request.generalId);
|
||||
if (!general || general.nationId !== request.nationId || general.userId !== request.userId)
|
||||
return '소속 또는 장수 소유자 변경';
|
||||
if ((incumbentFor(world, request.cityId, request.officerLevel)?.id ?? 0) !== request.incumbentId)
|
||||
return '재직자 변경';
|
||||
return eligibility(world, general, request.cityId, request.officerLevel).reason;
|
||||
};
|
||||
const approve = (world: InMemoryTurnWorld, request: OfficeRequest) => {
|
||||
const incumbent = incumbentFor(world, request.cityId, request.officerLevel);
|
||||
if (incumbent)
|
||||
world.updateGeneral(incumbent.id, {
|
||||
officerLevel: 1,
|
||||
meta: { ...incumbent.meta, officerCity: 0, officer_city: 0 },
|
||||
});
|
||||
const general = world.getGeneralById(request.generalId)!;
|
||||
world.updateGeneral(general.id, {
|
||||
officerLevel: request.officerLevel,
|
||||
meta: { ...general.meta, officerCity: request.cityId, officer_city: request.cityId },
|
||||
});
|
||||
const city = world.getCityById(request.cityId)!;
|
||||
world.updateCity(city.id, {
|
||||
meta: { ...city.meta, officer_set: Number(city.meta.officer_set ?? 0) | (1 << request.officerLevel) },
|
||||
});
|
||||
finish(world, request, 'approved', '임명');
|
||||
};
|
||||
|
||||
// 분기 lock 초기화 뒤 실행한다. 저장된 기본 거부는 조건이 완화되어도 자동 승인으로 바꾸지 않는다.
|
||||
export const settleCityOfficeRequests = (world: InMemoryTurnWorld, tick: number): void => {
|
||||
for (const general of world.listGenerals()) {
|
||||
const request = readOfficeRequest(general.meta);
|
||||
if (!request || request.status !== 'pending') continue;
|
||||
const invalid = invalidReason(world, request);
|
||||
if (invalid) {
|
||||
finish(world, request, 'cancelled', invalid);
|
||||
continue;
|
||||
}
|
||||
if (tick < request.dueTick) continue;
|
||||
const current = eligibility(world, general, request.cityId, request.officerLevel);
|
||||
if (request.defaultDecision === 'approve' && current.defaultDecision === 'approve') approve(world, request);
|
||||
else finish(world, request, 'rejected', '기한 만료 · 자동 거부');
|
||||
}
|
||||
};
|
||||
export const createCityOfficeRequestCalendarHandler = (
|
||||
getWorld: () => InMemoryTurnWorld | null
|
||||
): TurnCalendarHandler => ({
|
||||
beforeMonthChanged: () => {
|
||||
const world = getWorld();
|
||||
if (world) settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
|
||||
},
|
||||
onMonthChanged: (context) => {
|
||||
const world = getWorld();
|
||||
if (world) settleCityOfficeRequests(world, world.dateToGameTick(context.turnTime));
|
||||
},
|
||||
});
|
||||
|
||||
export const handleCityOfficeRequest = (
|
||||
world: InMemoryTurnWorld,
|
||||
command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
|
||||
acceptedAt: Date
|
||||
): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => {
|
||||
const fail = (reason: string): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => ({
|
||||
type: 'cityOfficeRequest',
|
||||
generalId: command.generalId,
|
||||
ok: false,
|
||||
reason,
|
||||
});
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor || actor.userId !== command.userId || !actor.nationId)
|
||||
return fail('장수 소유권 또는 소속이 변경되었습니다.');
|
||||
const tick = Math.max(world.dateToGameTick(world.getGameNow(acceptedAt)), world.getState().lastTurnTick ?? 0);
|
||||
settleCityOfficeRequests(world, tick);
|
||||
if (command.action === 'request') {
|
||||
if (!command.officerLevel) return fail('관직을 선택해 주세요.');
|
||||
const currentActor = world.getGeneralById(actor.id)!;
|
||||
const state = world.getState();
|
||||
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
|
||||
const previous = readOfficeRequest(currentActor.meta);
|
||||
if (previous?.status === 'pending') return fail('이미 대기 중인 요청이 있습니다.');
|
||||
if (previous?.quarter === quarter) return fail('이번 분기에 이미 자원했습니다.');
|
||||
const check = eligibility(world, currentActor, currentActor.cityId, command.officerLevel);
|
||||
if (!check.allowed) return fail(check.reason!);
|
||||
const occupied = world.listGenerals().some((g) => {
|
||||
const pending = readOfficeRequest(g.meta);
|
||||
return (
|
||||
pending?.status === 'pending' &&
|
||||
pending.cityId === actor.cityId &&
|
||||
pending.officerLevel === command.officerLevel
|
||||
);
|
||||
});
|
||||
if (occupied) return fail('이미 다른 장수가 자원한 관직입니다.');
|
||||
const boundary = state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime);
|
||||
const dueTick =
|
||||
boundary +
|
||||
Math.max(1, Math.ceil((tick - boundary + GAME_TICKS_PER_TURN) / GAME_TICKS_PER_TURN)) * GAME_TICKS_PER_TURN;
|
||||
save(world, actor.id, {
|
||||
id: `${actor.id}:${quarter}`,
|
||||
generalId: actor.id,
|
||||
userId: command.userId,
|
||||
nationId: actor.nationId,
|
||||
cityId: actor.cityId,
|
||||
officerLevel: command.officerLevel,
|
||||
incumbentId: incumbentFor(world, actor.cityId, command.officerLevel)?.id ?? 0,
|
||||
quarter,
|
||||
createdTick: tick,
|
||||
dueTick,
|
||||
defaultDecision: check.defaultDecision,
|
||||
defaultReason: check.defaultReason,
|
||||
status: 'pending',
|
||||
});
|
||||
} else {
|
||||
const target = world.getGeneralById(command.targetGeneralId ?? actor.id);
|
||||
const request = readOfficeRequest(target?.meta);
|
||||
if (!request || request.id !== command.officeRequestId || request.status !== 'pending')
|
||||
return fail('이미 처리되었거나 없는 요청입니다.');
|
||||
if (request.nationId !== actor.nationId) return fail('다른 국가의 요청입니다.');
|
||||
if (command.action === 'withdraw') {
|
||||
if (request.generalId !== actor.id) return fail('본인의 요청만 철회할 수 있습니다.');
|
||||
finish(world, request, 'withdrawn', '본인 철회');
|
||||
} else {
|
||||
if (actor.officerLevel < 5 || asRecord(actor.penalty).noChief) return fail('수뇌만 처리할 수 있습니다.');
|
||||
if (command.action === 'reject') finish(world, request, 'rejected', '수뇌 거부');
|
||||
else {
|
||||
if (asRecord(actor.penalty).noChiefChange && target && target.officerLevel >= 4)
|
||||
return fail('수뇌인 장수를 변경할 수 없는 상태입니다.');
|
||||
const invalid = invalidReason(world, request);
|
||||
if (invalid) return fail(invalid);
|
||||
approve(world, request);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'cityOfficeRequest', generalId: actor.id, ok: true };
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
GAME_TICKS_PER_TURN,
|
||||
readOfficeRequest,
|
||||
MAX_SAFE_GAME_TICK,
|
||||
GameClock,
|
||||
buildClockAlignmentPlan,
|
||||
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
|
||||
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'city-office-request-occurrence',
|
||||
'KEEP',
|
||||
generals.flatMap(({ id, meta }) => {
|
||||
const request = readOfficeRequest(meta);
|
||||
return request ? [{ id, createdTick: request.createdTick }] : [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'city-office-request-deadline',
|
||||
'SHIFT',
|
||||
generals.flatMap(({ id, meta }) => {
|
||||
const request = readOfficeRequest(meta);
|
||||
return request?.status === 'pending' && BigInt(request.dueTick) >= cutTick
|
||||
? [{ id, dueTick: request.dueTick }]
|
||||
: [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'auction-open-occurrence',
|
||||
'KEEP',
|
||||
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
|
||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||
const shift = BigInt(shiftTicks);
|
||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore, office] = await Promise.all([
|
||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
||||
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
|
||||
_max: { pickMoreFromTick: true },
|
||||
where: { pickMoreFromTick: { not: null } },
|
||||
}),
|
||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||
SELECT MAX((meta #>> '{cityOfficeRequest,dueTick}')::bigint) AS "maxTick"
|
||||
FROM general WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||
`),
|
||||
]);
|
||||
const values: Array<[string, bigint | null]> = [
|
||||
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
|
||||
['general.turn_tick', general._max.turnTick],
|
||||
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
||||
['auction.close_tick', auction._max.closeTick],
|
||||
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
|
||||
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'city-office-request-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE general SET meta = jsonb_set(meta, '{cityOfficeRequest,dueTick}',
|
||||
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${shiftTicks}))
|
||||
WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||
AND (meta #>> '{cityOfficeRequest,dueTick}')::bigint >= ${cutTick}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'auction-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
|
||||
@@ -185,6 +185,16 @@ const zKick = z.object({
|
||||
destGeneralId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zCityOfficeRequest = z.object({
|
||||
type: z.literal('cityOfficeRequest'),
|
||||
userId: z.string().min(1),
|
||||
generalId: z.number().int().positive(),
|
||||
action: z.enum(['request', 'approve', 'reject', 'withdraw']),
|
||||
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
|
||||
targetGeneralId: z.number().int().positive().optional(),
|
||||
officeRequestId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const zAppoint = z.object({
|
||||
type: z.literal('appoint'),
|
||||
userId: z.string().min(1),
|
||||
@@ -666,6 +676,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) {
|
||||
@@ -883,6 +898,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
changePermission: normalizeChangePermission,
|
||||
kick: normalizeKick,
|
||||
appoint: normalizeAppoint,
|
||||
cityOfficeRequest: normalizeCityOfficeRequest,
|
||||
tournamentRefund: normalizeTournamentRefund,
|
||||
tournamentBettingPayout: normalizeTournamentBettingPayout,
|
||||
tournamentReward: normalizeTournamentReward,
|
||||
|
||||
@@ -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 {
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queueDiplomacy',
|
||||
() => {
|
||||
this.pendingAuditDiplomacy.push(structuredClone(event));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
queueAuditDecision(decision: PendingAuditDecision): void {
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queueDecision',
|
||||
() => {
|
||||
this.pendingAuditDecisions.push(structuredClone(decision));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
queueAuditPolicy(policy: PendingAuditPolicy): void {
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queuePolicy',
|
||||
() => {
|
||||
this.pendingAuditPolicies.push(structuredClone(policy));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
hasPendingAuditRecords(): boolean {
|
||||
@@ -1414,7 +1439,14 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
|
||||
queueAuditMonth(snapshot: PendingAuditMonth): void {
|
||||
collectPlayAudit(
|
||||
this,
|
||||
'queueMonth',
|
||||
() => {
|
||||
this.pendingAuditMonths.push(structuredClone(snapshot));
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
|
||||
|
||||
@@ -396,7 +396,8 @@ export const createRaiseInvaderHandler = (options: {
|
||||
}
|
||||
world.updateNation(nationId, {
|
||||
chiefGeneralId: ruler.id,
|
||||
meta: { ...nation.meta, gennum: npcEachCount },
|
||||
// addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
|
||||
meta: { ...world.getNationById(nationId)!.meta, gennum: npcEachCount },
|
||||
});
|
||||
for (const officerLevel of [12, 11, 10, 9]) {
|
||||
options.reservedTurns.ensureNationTurns(nationId, officerLevel);
|
||||
|
||||
@@ -395,7 +395,8 @@ export const createRaiseNpcNationHandler = (options: {
|
||||
}
|
||||
world.updateNation(nationId, {
|
||||
chiefGeneralId: ruler.id,
|
||||
meta: { ...nation.meta, gennum: 1 + subordinateCandidates.length },
|
||||
// addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
|
||||
meta: { ...world.getNationById(nationId)!.meta, gennum: 1 + subordinateCandidates.length },
|
||||
});
|
||||
options.reservedTurns.ensureNationTurns(nationId, 12);
|
||||
options.reservedTurns.ensureNationTurns(nationId, 11);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectPlayAudit } from '../playAudit/bestEffort.js';
|
||||
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
|
||||
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
|
||||
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
|
||||
@@ -1085,7 +1086,11 @@ export const createReservedTurnHandler = async (options: {
|
||||
const onDecisionTrace: AiDecisionTraceObserver | undefined =
|
||||
collectDecisions || options.onDecisionTrace
|
||||
? (event) => {
|
||||
if (collectDecisions) {
|
||||
if (collectDecisions && worldRef)
|
||||
collectPlayAudit(
|
||||
worldRef,
|
||||
'decision-trace',
|
||||
() => {
|
||||
if (event.kind === 'DECISION_START') {
|
||||
decisionSteps.set(event.phase, []);
|
||||
const heads = asRecord(currentNation?.meta._playAuditPolicy);
|
||||
@@ -1094,7 +1099,8 @@ export const createReservedTurnHandler = async (options: {
|
||||
Object.fromEntries(
|
||||
AUDIT_POLICY_AREAS.flatMap((area) => {
|
||||
const head = asRecord(heads[area]);
|
||||
return head.serverId === auditServerId && typeof head.id === 'string'
|
||||
return head.serverId === auditServerId &&
|
||||
typeof head.id === 'string'
|
||||
? [[area, head.id]]
|
||||
: [];
|
||||
})
|
||||
@@ -1104,11 +1110,18 @@ export const createReservedTurnHandler = async (options: {
|
||||
decisionSteps
|
||||
.get(event.phase)
|
||||
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
|
||||
}
|
||||
},
|
||||
undefined
|
||||
);
|
||||
options.onDecisionTrace?.(event);
|
||||
}
|
||||
: undefined;
|
||||
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
|
||||
if (!worldRef) return;
|
||||
collectPlayAudit(
|
||||
worldRef,
|
||||
'decision-summary',
|
||||
() => {
|
||||
const steps = decisionSteps.get(phase);
|
||||
const first = steps?.[0];
|
||||
if (!collectDecisions || !first || !steps) return;
|
||||
@@ -1125,6 +1138,9 @@ export const createReservedTurnHandler = async (options: {
|
||||
month,
|
||||
tick,
|
||||
});
|
||||
},
|
||||
undefined
|
||||
);
|
||||
};
|
||||
const finishDecision = (
|
||||
phase: 'general' | 'nation',
|
||||
@@ -1135,6 +1151,11 @@ export const createReservedTurnHandler = async (options: {
|
||||
blockedReason?: string;
|
||||
}
|
||||
): void => {
|
||||
if (!worldRef) return;
|
||||
collectPlayAudit(
|
||||
worldRef,
|
||||
'decision-summary',
|
||||
() => {
|
||||
const steps = decisionSteps.get(phase);
|
||||
const first = steps?.[0];
|
||||
const last = steps?.find((step) => step.kind === 'DECISION_END');
|
||||
@@ -1193,6 +1214,9 @@ export const createReservedTurnHandler = async (options: {
|
||||
},
|
||||
steps,
|
||||
});
|
||||
},
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
|
||||
|
||||
@@ -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,
|
||||
@@ -187,6 +188,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'changePermission',
|
||||
'kick',
|
||||
'appoint',
|
||||
'cityOfficeRequest',
|
||||
'voteReward',
|
||||
'syncDiplomaticResponse',
|
||||
] as const satisfies readonly TurnDaemonCommand['type'][];
|
||||
@@ -2587,6 +2589,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 +3246,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' }>),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -45,6 +45,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
|
||||
);
|
||||
|
||||
@@ -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 $$"
|
||||
@@ -100,28 +101,34 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
await db.$executeRawUnsafe(
|
||||
"CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()"
|
||||
);
|
||||
try {
|
||||
let error: unknown;
|
||||
try {
|
||||
runtime = await start();
|
||||
} catch (cause) {
|
||||
error = cause;
|
||||
}
|
||||
expect(String(error)).toContain('fixture initial audit failure');
|
||||
expect(await db.playAuditMonth.count()).toBe(0);
|
||||
expect(await db.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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('포상');
|
||||
|
||||
@@ -245,10 +245,7 @@ test('shows and operates a city target map through live PostgreSQL, Game API, an
|
||||
expect(geometry.display).not.toBe('none');
|
||||
await page.screenshot({ path: testInfo.outputPath('chief-command-city-map-live.png'), fullPage: true });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: '명령 입력 닫기', exact: true })
|
||||
.click({ timeout: 2_000 })
|
||||
.catch(() => undefined);
|
||||
await page.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
const nationPicker = page.getByTestId('command-picker');
|
||||
await nationPicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||||
@@ -264,10 +261,7 @@ test('shows and operates a city target map through live PostgreSQL, Game API, an
|
||||
);
|
||||
await page.screenshot({ path: testInfo.outputPath('chief-command-nation-map-live.png'), fullPage: true });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: '명령 입력 닫기', exact: true })
|
||||
.click({ timeout: 2_000 })
|
||||
.catch(() => undefined);
|
||||
await page.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||||
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||||
const assignmentPicker = page.getByTestId('command-picker');
|
||||
await assignmentPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 { buildRefGeneralTargetOptions } from '../../game-api/src/turns/commandTargets.js';
|
||||
import {
|
||||
buildRefGeneralTargetOptions,
|
||||
buildRefNationTargetOptions,
|
||||
type NationTargetSource,
|
||||
} from '../../game-api/src/turns/commandTargets.js';
|
||||
import { purifyNationHtml } from '../../game-api/src/security/nationHtml.js';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
@@ -848,7 +854,8 @@ const install = async (
|
||||
clockRecovery: { startsAt: string; endsAt: string } | null;
|
||||
turnEngineRunning: boolean;
|
||||
},
|
||||
decoratedNpcChiefs = false
|
||||
decoratedNpcChiefs = false,
|
||||
chiefOfficerLevel = 5
|
||||
) => {
|
||||
const requests: unknown[] = [];
|
||||
const currentGeneralContext = {
|
||||
@@ -994,6 +1001,18 @@ const install = async (
|
||||
npcState: [4, 6, 9, 7, 5, 3, 2, 0][index]!,
|
||||
})),
|
||||
}
|
||||
: chiefOfficerLevel !== chiefCenter.me.officerLevel
|
||||
? {
|
||||
...chiefCenter,
|
||||
me: { ...chiefCenter.me, officerLevel: chiefOfficerLevel },
|
||||
chiefs: chiefCenter.chiefs.map((chief) => ({
|
||||
...chief,
|
||||
name:
|
||||
chief.officerLevel === chiefOfficerLevel
|
||||
? '장수'
|
||||
: `수뇌${chief.officerLevel}`,
|
||||
})),
|
||||
}
|
||||
: chiefCenter
|
||||
);
|
||||
if (name === 'turns.reserved.getGeneral')
|
||||
@@ -1237,7 +1256,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
|
||||
);
|
||||
await form.locator('select').selectOption('2');
|
||||
await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') });
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행');
|
||||
expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}');
|
||||
|
||||
@@ -1355,7 +1374,7 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
|
||||
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
|
||||
await mobilePicker.getByLabel('국가명').fill('가나다라마바사아자차');
|
||||
await expect(mobilePicker.getByText('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.')).toBeVisible();
|
||||
await expect(mobilePicker.getByRole('button', { name: '입력', exact: true })).toBeDisabled();
|
||||
await expect(mobilePicker.getByRole('button', { name: / 입력$/ })).toBeDisabled();
|
||||
await mobilePicker.getByLabel('국가명').fill('신국');
|
||||
const mobileColorType = mobilePicker.getByLabel('국기 색상');
|
||||
await mobileColorType.click();
|
||||
@@ -1384,7 +1403,7 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
|
||||
expect(colorSelectRect.right).toBeLessThanOrEqual(500);
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
||||
await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') });
|
||||
await mobilePicker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await mobilePicker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toContainText('신국');
|
||||
await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15');
|
||||
});
|
||||
@@ -1499,7 +1518,7 @@ test('shows speciality reset cooldowns and reserves special user commands from t
|
||||
const forceMoveForm = picker.getByTestId('command-argument-form');
|
||||
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
|
||||
await forceMoveForm.locator('select').selectOption('2');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행');
|
||||
|
||||
const serialized = JSON.stringify(requests);
|
||||
@@ -1585,6 +1604,98 @@ test('shows general command durations from metadata on desktop and mobile', asyn
|
||||
expect(JSON.stringify(requests)).toContain('"action":"che_내정특기초기화","args":{}');
|
||||
});
|
||||
|
||||
for (const { officerLevel, row, column } of [
|
||||
{ officerLevel: 12, row: 0, column: 0 },
|
||||
{ officerLevel: 6, row: 0, column: 3 },
|
||||
{ officerLevel: 9, row: 1, column: 1 },
|
||||
{ officerLevel: 5, row: 1, column: 3 },
|
||||
]) {
|
||||
test(`anchors the chief command list to officer ${officerLevel} column and clicked turn`, async ({ page }) => {
|
||||
await install(page, false, refChiefCommandTable, 1, undefined, false, officerLevel);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('/che/chief-center');
|
||||
|
||||
const editor = page.locator('[data-command-scope="nation"]');
|
||||
const gridRow = page.locator('.chief-grid-row').nth(row);
|
||||
await expect(gridRow.locator('[data-command-scope="nation"]')).toHaveCount(1);
|
||||
const picker = page.getByTestId('command-picker');
|
||||
const pageBox = (await page.locator('.layout-desktop').boundingBox())!;
|
||||
const artifacts: Record<string, unknown>[] = [];
|
||||
for (const turn of [1, 7, 12]) {
|
||||
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
|
||||
await expect(picker.locator('.category-btn').first()).toBeVisible();
|
||||
const geometry = await page.evaluate((turnIndex) => {
|
||||
const editorElement = document.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
|
||||
const pickerElement = document.querySelector<HTMLElement>('[data-testid="command-picker"]')!;
|
||||
const rowElement = editorElement.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`)!;
|
||||
const rect = (element: Element) => {
|
||||
const { left, top, right, bottom, width, height } = element.getBoundingClientRect();
|
||||
return { left, top, right, bottom, width, height };
|
||||
};
|
||||
return {
|
||||
editor: rect(editorElement),
|
||||
picker: rect(pickerElement),
|
||||
row: rect(rowElement),
|
||||
position: getComputedStyle(pickerElement).position,
|
||||
horizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
pickerOverflow: pickerElement.scrollWidth - pickerElement.clientWidth,
|
||||
};
|
||||
}, turn - 1);
|
||||
artifacts.push({ turn, ...geometry });
|
||||
// Ref PageChiefCenter는 24px 턴 번호 열 뒤에 238px 수뇌 열 4개를 둔다.
|
||||
expect(geometry.editor.left - pageBox.x).toBeCloseTo(24 + column * 238, 0);
|
||||
expect(geometry.position).toBe('absolute');
|
||||
expect(geometry.picker.left).toBeCloseTo(geometry.editor.left, 0);
|
||||
expect(geometry.picker.width).toBeCloseTo(geometry.editor.width, 0);
|
||||
expect(geometry.picker.top).toBeCloseTo(geometry.row.bottom, 0);
|
||||
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||
expect(geometry.pickerOverflow).toBeLessThanOrEqual(0);
|
||||
// 둘째 줄 마지막 턴에서도 목록이 문서 밖으로 잘리지 않고 스크롤로 전부 보여야 한다.
|
||||
await picker.evaluate((element) => element.scrollIntoView({ block: 'nearest' }));
|
||||
const visible = await picker.evaluate((element) => {
|
||||
const { top, bottom } = element.getBoundingClientRect();
|
||||
return { top, bottom, viewportHeight: window.innerHeight };
|
||||
});
|
||||
expect(visible.top).toBeGreaterThanOrEqual(0);
|
||||
expect(visible.bottom).toBeLessThanOrEqual(visible.viewportHeight);
|
||||
if (turn !== 1) {
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`chief-picker-officer-${officerLevel}-turn-${turn}-1200.png`),
|
||||
});
|
||||
}
|
||||
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
|
||||
await expect(picker).toHaveCount(0);
|
||||
}
|
||||
// 고급 모드의 `명령 선택 ▾`은 Ref 편집 모드(`bottom: 0`)처럼 자기 열 턴 표 하단에 맞춰 위로 펼친다.
|
||||
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||||
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
|
||||
await expect(picker.locator('.category-btn').first()).toBeVisible();
|
||||
const advanced = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const { left, top, bottom, width } = document.querySelector(selector)!.getBoundingClientRect();
|
||||
return { left, top, bottom, width };
|
||||
};
|
||||
return {
|
||||
editor: rect('[data-command-scope="nation"]'),
|
||||
picker: rect('[data-testid="command-picker"]'),
|
||||
queue: rect('[data-command-scope="nation"] .queue-area'),
|
||||
};
|
||||
});
|
||||
artifacts.push({ turn: 'advanced', ...advanced });
|
||||
expect(advanced.picker.left).toBeCloseTo(advanced.editor.left, 0);
|
||||
expect(advanced.picker.width).toBeCloseTo(advanced.editor.width, 0);
|
||||
expect(advanced.picker.top).toBeGreaterThanOrEqual(advanced.editor.top + 24);
|
||||
expect(advanced.picker.bottom).toBeCloseTo(advanced.queue.bottom, 0);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`chief-picker-officer-${officerLevel}-advanced-1200.png`),
|
||||
});
|
||||
await writeFile(
|
||||
test.info().outputPath(`chief-picker-officer-${officerLevel}-geometry.json`),
|
||||
JSON.stringify(artifacts, null, 2)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('shows every Ref chief command in the exact category and command order', async ({ page }) => {
|
||||
await install(page, false, refChiefCommandTable);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
@@ -1669,7 +1780,7 @@ test('keeps general and chief command categories after input and across page rel
|
||||
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '출병', exact: true }).click();
|
||||
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(generalEditor.locator('.action-column > div').first()).toHaveText('【단양】으로 출병');
|
||||
|
||||
await generalEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
@@ -1845,8 +1956,8 @@ test('shows all 12 advanced chief turns before the actions and uses the full mob
|
||||
expect(geometry.frameWidth).toBe(500);
|
||||
expect(geometry.rowWidth).toBe(500);
|
||||
expect(geometry.rowEdges).toEqual([
|
||||
{ top: 0, bottom: 155 },
|
||||
{ top: 155, bottom: 310 },
|
||||
{ top: 0, bottom: 224 },
|
||||
{ top: 224, bottom: 448 },
|
||||
]);
|
||||
expect(geometry.gutterWidths).toEqual([12, 12]);
|
||||
expect(geometry.gutterEdges).toEqual([
|
||||
@@ -2032,7 +2143,10 @@ test('enters general and nation command arguments and sends exact values', async
|
||||
const rect = area.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
||||
await page
|
||||
.getByTestId('command-picker')
|
||||
.getByRole('button', { name: / 입력$/ })
|
||||
.click();
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||||
'【허창】에 화계실행'
|
||||
);
|
||||
@@ -2059,7 +2173,7 @@ test('enters general and nation command arguments and sends exact values', async
|
||||
fontSize: style.fontSize,
|
||||
};
|
||||
});
|
||||
await chiefPicker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await chiefPicker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
||||
'【여포NPC】 쌀 300 포상'
|
||||
);
|
||||
@@ -2124,8 +2238,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
||||
await expect(picker).toHaveAttribute('role', 'dialog');
|
||||
await expect(picker).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
|
||||
await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
|
||||
const form = picker.getByTestId('recruitment-command-form');
|
||||
await verifyArgumentActionPlacement(page, picker);
|
||||
await expect(form).toContainText('현재 기술력 : 1등급');
|
||||
await expect(form).toContainText('공격');
|
||||
await expect(form).toContainText('방어');
|
||||
@@ -2184,7 +2299,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
await infantry.getByRole('button', { name: '절반', exact: true }).click();
|
||||
await expect(estimatedGold).toHaveText('363금');
|
||||
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||||
'【보병】 3500명 징병'
|
||||
);
|
||||
@@ -2196,7 +2311,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
||||
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
|
||||
await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
|
||||
const referenceWidthGeometry = await picker.evaluate((element) => {
|
||||
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
|
||||
const row = formElement.querySelector<HTMLElement>('.crew-row')!;
|
||||
@@ -2243,7 +2358,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
infoWidth: info?.getBoundingClientRect().width ?? 0,
|
||||
selectedWidth: selectedPanel?.getBoundingClientRect().width ?? 0,
|
||||
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
|
||||
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')?.getBoundingClientRect().bottom ?? 0,
|
||||
formBottom: formElement.getBoundingClientRect().bottom,
|
||||
actionsHeight: element.querySelector<HTMLElement>('.picker-actions')!.getBoundingClientRect().height,
|
||||
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')!.getBoundingClientRect().bottom,
|
||||
viewportHeight: window.innerHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
@@ -2260,10 +2377,13 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
imageWidth: 64,
|
||||
selectedWidth: 390,
|
||||
selectedDisplay: 'grid',
|
||||
actionsBottom: 844,
|
||||
viewportHeight: 844,
|
||||
bodyOverflow: 'hidden',
|
||||
});
|
||||
expect(mobileGeometry.actionsBottom).toBeCloseTo(
|
||||
Math.min(mobileGeometry.formBottom + mobileGeometry.actionsHeight, 844),
|
||||
0
|
||||
);
|
||||
expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64);
|
||||
expect(mobileGeometry.infoWidth).toBeGreaterThanOrEqual(150);
|
||||
expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth);
|
||||
@@ -2277,6 +2397,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
headerTop: header.getBoundingClientRect().top,
|
||||
headerBottom: header.getBoundingClientRect().bottom,
|
||||
listFrontTop: listFront.getBoundingClientRect().top,
|
||||
actionsBottom: actions.getBoundingClientRect().bottom,
|
||||
viewportHeight: window.innerHeight,
|
||||
@@ -2284,7 +2405,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
});
|
||||
expect(stickyGeometry.scrollTop).toBeGreaterThan(0);
|
||||
expect(stickyGeometry.headerTop).toBe(0);
|
||||
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(44);
|
||||
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(stickyGeometry.headerBottom);
|
||||
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
@@ -2306,7 +2427,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
await unavailableMercenary.click();
|
||||
await mercenaryForm.locator('.mobile-selected-panel input[type=number]').fill('25');
|
||||
await expect(mercenaryForm.locator('.mobile-selected-panel .submit-recruit')).toBeEnabled();
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(1)).toHaveText(
|
||||
'【정예병】 2500명 모병'
|
||||
);
|
||||
@@ -2592,7 +2713,7 @@ test('limits non-aggression end years to the Ref twenty-year window on desktop a
|
||||
await expect(yearInput).toHaveAttribute('max', '210');
|
||||
await expect(yearInput).toHaveValue('191');
|
||||
|
||||
const submit = picker.getByRole('button', { name: '입력', exact: true });
|
||||
const submit = picker.getByRole('button', { name: / 입력$/ });
|
||||
await yearInput.fill('211');
|
||||
await expect(submit).toBeDisabled();
|
||||
await yearInput.fill('210');
|
||||
@@ -2880,7 +3001,7 @@ test('offers Ref amount presets and rich, command-specific general lists', async
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
|
||||
await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
|
||||
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await awardResponse;
|
||||
|
||||
await page.goto('/che/chief-center');
|
||||
@@ -2945,7 +3066,7 @@ test('offers Ref amount presets and rich, command-specific general lists', async
|
||||
await expect(tupleInputs.nth(0)).toHaveValue('20000');
|
||||
await expect(tupleInputs.nth(1)).toHaveValue('1370');
|
||||
const aidResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await aidResponse;
|
||||
|
||||
const serialized = JSON.stringify(requests);
|
||||
@@ -3087,7 +3208,7 @@ for (const width of [1200, 500]) {
|
||||
);
|
||||
await picker.screenshot({ path: testInfo.outputPath('assignment-details.png') });
|
||||
const saved = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await saved;
|
||||
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
|
||||
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
||||
@@ -3162,7 +3283,7 @@ test('keeps the entered command visible and reports a server validation error',
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||||
await page.getByTestId('command-argument-form').locator('select').selectOption('2');
|
||||
const submit = page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true });
|
||||
const submit = page.getByTestId('command-picker').getByRole('button', { name: / 입력$/ });
|
||||
await submit.click();
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.');
|
||||
@@ -3211,7 +3332,7 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
|
||||
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '출병', exact: true }).click();
|
||||
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
|
||||
await expect(firstRow).toHaveText('【단양】으로 출병');
|
||||
await expect(firstRow).toHaveAttribute('data-autorun-tooltip', /자율 행동: 200年 3月 · .*까지/u);
|
||||
@@ -3299,12 +3420,12 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
|
||||
await drag(0, 2);
|
||||
await expect(editor.locator('.index-column > button.selected')).toHaveCount(3);
|
||||
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
|
||||
const picker = editor.getByTestId('command-picker');
|
||||
const picker = page.getByTestId('command-picker');
|
||||
const blockedFire = picker.getByRole('button', { name: '화계', exact: true });
|
||||
await expect(blockedFire).toBeEnabled();
|
||||
await blockedFire.click();
|
||||
await picker.getByTestId('command-argument-form').locator('select').selectOption('2');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
|
||||
|
||||
const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) });
|
||||
@@ -3494,7 +3615,10 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
|
||||
await chiefArgumentForm.locator('input[type=number]').fill('300');
|
||||
await chiefArgumentForm.locator('select').selectOption('2');
|
||||
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
||||
await page
|
||||
.getByTestId('command-picker')
|
||||
.getByRole('button', { name: / 입력$/ })
|
||||
.click();
|
||||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
||||
'【관우】 쌀 300 포상'
|
||||
);
|
||||
@@ -3717,7 +3841,7 @@ for (const width of [1200, 390]) {
|
||||
let picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /화계/ }).click();
|
||||
let form = picker.getByTestId('command-argument-form');
|
||||
const toggle = form.getByRole('button', { name: '검색 꺼짐', exact: true });
|
||||
const toggle = picker.getByRole('button', { name: '검색 꺼짐', exact: true });
|
||||
await expect(form.locator('input[type=search]')).toHaveCount(0);
|
||||
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
|
||||
await form.getByTestId('city-target-list').getByRole('button', { name: /허창/ }).click();
|
||||
@@ -3743,11 +3867,11 @@ for (const width of [1200, 390]) {
|
||||
await input.press('Enter');
|
||||
await expect(input).not.toBeFocused();
|
||||
await expect(picker).toBeVisible();
|
||||
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(results.locator('button')).toHaveCount(3);
|
||||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect.poll(() => JSON.stringify(requests)).toContain('"destCityId":2');
|
||||
|
||||
await page.goto(gamePath('/chief-center'));
|
||||
@@ -3758,7 +3882,7 @@ for (const width of [1200, 390]) {
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(3);
|
||||
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
|
||||
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
const generalSearch = form.locator('#command-search-destGeneralId');
|
||||
const citySearch = form.locator('#command-search-destCityId');
|
||||
for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) {
|
||||
@@ -3793,7 +3917,7 @@ for (const width of [1200, 390]) {
|
||||
expect(geometry.viewport.scale).toBe(initialScale);
|
||||
await writeFile(testInfo.outputPath(`search-${width}.json`), JSON.stringify(geometry, null, 2));
|
||||
await form.screenshot({ path: testInfo.outputPath(`search-${width}.png`) });
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect.poll(() => JSON.stringify(requests)).toContain('"destGeneralId":2,"destCityId":2');
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
@@ -3801,7 +3925,7 @@ for (const width of [1200, 390]) {
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /선전포고/ }).click();
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
await expect(form.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
|
||||
await expect(picker.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
@@ -3821,7 +3945,7 @@ for (const width of [1200, 390]) {
|
||||
await input.press('Escape');
|
||||
await expect(input).toHaveValue('');
|
||||
await expect(picker).toBeVisible();
|
||||
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(results.locator('button')).toHaveCount(3);
|
||||
});
|
||||
@@ -3856,7 +3980,7 @@ test('gift search indexes nullable names without matching the displayed no-troop
|
||||
const results = form.getByTestId('general-target-list');
|
||||
await expect(results.locator('button')).toHaveCount(3);
|
||||
await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음');
|
||||
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
const search = form.locator('input[type=search]');
|
||||
await search.fill('ㅇㅇ');
|
||||
await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']);
|
||||
@@ -3896,7 +4020,7 @@ test('search reflects an active Korean IME composition after debounce without co
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /증여/ }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
const input = form.locator('input[type=search]');
|
||||
const results = form.getByTestId('general-target-list');
|
||||
await input.focus();
|
||||
@@ -3928,11 +4052,1002 @@ test('search reflects an active Korean IME composition after debounce without co
|
||||
await form.getByRole('button', { name: '지우기', exact: true }).click();
|
||||
await expect(results.locator('button')).toHaveCount(3);
|
||||
await input.fill('ㄱㅇ');
|
||||
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||
await page.waitForTimeout(250); // 예약된 디바운스가 OFF 이후 재적용되지 않는지 확인한다.
|
||||
await expect(results.locator('button')).toHaveCount(3);
|
||||
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
await expect(input).toHaveValue('');
|
||||
await expect(results.locator('button')).toHaveCount(3);
|
||||
await cdp.detach();
|
||||
});
|
||||
|
||||
// 짧은 폼은 바로 뒤에 확인 영역을 두고, 긴 폼은 스크롤 중에도 하단에서 확인할 수 있다.
|
||||
const verifyArgumentActionPlacement = async (page: Page, overlay: ReturnType<Page['getByRole']>) => {
|
||||
const viewport = page.viewportSize()!;
|
||||
for (const height of [1600, 320]) {
|
||||
await page.setViewportSize({ width: viewport.width, height });
|
||||
await overlay.evaluate((element) => (element.scrollTop = 0));
|
||||
const measure = () =>
|
||||
overlay.evaluate((element) => {
|
||||
const form = element.querySelector<HTMLElement>('.command-argument-form, .recruitment-command-form')!;
|
||||
const actions = element.querySelector<HTMLElement>('.picker-actions')!;
|
||||
return {
|
||||
formBottom: form.getBoundingClientRect().bottom,
|
||||
actionsTop: actions.getBoundingClientRect().top,
|
||||
actionsBottom: actions.getBoundingClientRect().bottom,
|
||||
scrollTop: element.scrollTop,
|
||||
};
|
||||
});
|
||||
if (height === 1600) {
|
||||
const geometry = await measure();
|
||||
expect(geometry.actionsTop).toBeCloseTo(geometry.formBottom, 0);
|
||||
expect(geometry.actionsBottom).toBeLessThan(height - 100);
|
||||
} else {
|
||||
expect(Math.abs((await measure()).actionsBottom - height)).toBeLessThanOrEqual(1);
|
||||
await overlay.evaluate((element) => (element.scrollTop = 120));
|
||||
const geometry = await measure();
|
||||
expect(geometry.scrollTop).toBeGreaterThan(0);
|
||||
expect(Math.abs(geometry.actionsBottom - height)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize(viewport);
|
||||
await overlay.evaluate((element) => (element.scrollTop = 0));
|
||||
};
|
||||
|
||||
// Ref v_processing처럼 인자 입력은 화면 전체를 차지하되, Core는 URL을 바꾸지 않고
|
||||
// Back·명령 취소·명령 다시 선택·Esc로 같은 문서의 overlay만 닫는다.
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 900 },
|
||||
{ width: 500, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
] as const) {
|
||||
test(`opens every argument input as a full-screen overlay with a top control bar at ${viewport.width}px`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(gamePath('/'));
|
||||
const mainUrl = page.url();
|
||||
const initialHistoryLength = await page.evaluate(() => history.length);
|
||||
const editor = page.locator('[data-command-scope="general"]:visible');
|
||||
|
||||
const openFireAttack = async (turn: number) => {
|
||||
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
|
||||
const listPicker = page.getByTestId('command-picker');
|
||||
await expect(listPicker).not.toHaveAttribute('role', 'dialog');
|
||||
await listPicker.getByRole('button', { name: /화계/ }).click();
|
||||
const overlay = page.getByRole('dialog', { name: `화계 ${turn}턴 명령 입력` });
|
||||
await expect(overlay).toBeVisible();
|
||||
return overlay;
|
||||
};
|
||||
const measure = (overlay: ReturnType<Page['getByRole']>) =>
|
||||
overlay.evaluate((element) => {
|
||||
const bar = element.querySelector<HTMLElement>('[data-testid="command-input-top-bar"]')!;
|
||||
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"]');
|
||||
const fields = element.querySelector<HTMLElement>('.argument-fields');
|
||||
const rect = (target: Element | null) => target?.getBoundingClientRect().toJSON() ?? null;
|
||||
return {
|
||||
parentIsBody: element.parentElement === document.body,
|
||||
overlay: rect(element),
|
||||
bar: rect(bar),
|
||||
buttons: [...bar.querySelectorAll('button')].map((button) => ({
|
||||
text: button.textContent?.trim(),
|
||||
background: getComputedStyle(button).backgroundColor,
|
||||
...button.getBoundingClientRect().toJSON(),
|
||||
})),
|
||||
submit: (() => {
|
||||
const submit = element.querySelector<HTMLButtonElement>('.picker-actions > .command-submit');
|
||||
if (!submit) return null;
|
||||
return {
|
||||
text: submit.textContent?.trim(),
|
||||
disabled: submit.disabled,
|
||||
background: getComputedStyle(submit).backgroundColor,
|
||||
fontWeight: getComputedStyle(submit).fontWeight,
|
||||
...submit.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
})(),
|
||||
title: bar.querySelector('h2')?.textContent?.replace(/\s+/g, ' ').trim(),
|
||||
map: rect(map),
|
||||
fields: rect(fields),
|
||||
form: rect(element.querySelector('.command-argument-form')),
|
||||
actions: rect(element.querySelector('.picker-actions')),
|
||||
bodyOverflow: getComputedStyle(document.body).overflow,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
let overlay = await openFireAttack(1);
|
||||
await expect(overlay).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(overlay.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await measure(overlay);
|
||||
await writeFile(
|
||||
testInfo.outputPath(`argument-overlay-${viewport.width}.json`),
|
||||
JSON.stringify(geometry, null, 2)
|
||||
);
|
||||
await page.screenshot({ path: testInfo.outputPath(`argument-overlay-${viewport.width}.png`) });
|
||||
expect(geometry.parentIsBody).toBe(true);
|
||||
expect(geometry.overlay).toMatchObject({ x: 0, y: 0, width: viewport.width, height: viewport.height });
|
||||
expect(geometry.bar?.y).toBe(0);
|
||||
expect(geometry.buttons.map((button) => button.text)).toEqual(['명령 취소', '명령 다시 선택', '검색 꺼짐']);
|
||||
expect(geometry.title).toBe('화계1턴');
|
||||
// 녹색은 Ref 돌아가기와 같은 이동 버튼(명령 취소)에만 쓰고, 제출은 명령명을 붙인 primary(파랑) 버튼이다.
|
||||
expect(geometry.buttons.map((button) => button.background)).toEqual([
|
||||
'rgb(0, 88, 44)',
|
||||
'rgb(68, 68, 68)',
|
||||
'rgb(68, 68, 68)',
|
||||
]);
|
||||
expect(geometry.submit).toMatchObject({
|
||||
text: '화계 입력',
|
||||
disabled: false,
|
||||
background: 'rgb(55, 90, 127)',
|
||||
fontWeight: '700',
|
||||
});
|
||||
expect(geometry.submit!.width).toBe(Math.min(360, viewport.width - 12));
|
||||
expect(geometry.submit!.x + geometry.submit!.width / 2).toBeCloseTo(viewport.width / 2, 0);
|
||||
expect(geometry.submit!.height).toBeGreaterThanOrEqual(40);
|
||||
expect(geometry.submit!.bottom).toBeLessThanOrEqual(viewport.height);
|
||||
expect(geometry.actions!.top).toBeCloseTo(
|
||||
Math.min(geometry.form!.bottom, viewport.height - geometry.actions!.height),
|
||||
0
|
||||
);
|
||||
await verifyArgumentActionPlacement(page, overlay);
|
||||
expect(geometry.bodyOverflow).toBe('hidden');
|
||||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||||
for (const button of geometry.buttons) {
|
||||
expect(button.x).toBeGreaterThanOrEqual(0);
|
||||
expect(button.right).toBeLessThanOrEqual(viewport.width);
|
||||
}
|
||||
if (viewport.width >= 1120) {
|
||||
// 원본 700px 지도 오른쪽에 대상 목록 열을 두고 1120px 폭(1px 테두리 포함) 안에서 중앙 정렬한다.
|
||||
expect(geometry.map).toMatchObject({ x: (viewport.width - 1120) / 2 + 1, width: 700 });
|
||||
expect(geometry.fields?.x).toBe(geometry.map!.right);
|
||||
expect(geometry.fields?.y).toBe(geometry.map!.y);
|
||||
expect(geometry.fields?.right).toBe((viewport.width + 1120) / 2 - 1);
|
||||
} else {
|
||||
expect(geometry.fields!.y).toBeGreaterThanOrEqual(geometry.map!.bottom);
|
||||
expect(geometry.map!.width).toBeLessThanOrEqual(viewport.width);
|
||||
}
|
||||
|
||||
// 검색 토글은 상단 바에 있고 인자 폼의 검색 입력을 제어한다.
|
||||
await overlay.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
await expect(overlay.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(overlay.locator('input[type=search]')).toHaveCount(1);
|
||||
await overlay.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||
await expect(overlay.locator('input[type=search]')).toHaveCount(0);
|
||||
|
||||
// 명령 취소: overlay와 명령 목록을 함께 닫고 Back entry를 소비한다.
|
||||
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||||
await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1);
|
||||
expect(page.url()).toBe(mainUrl);
|
||||
|
||||
// 브라우저 Back: 페이지를 떠나지 않고 overlay만 닫는다.
|
||||
await openFireAttack(2);
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||||
expect(page.url()).toBe(mainUrl);
|
||||
await expect(editor).toBeVisible();
|
||||
|
||||
// Esc
|
||||
await openFireAttack(3);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||||
expect(page.url()).toBe(mainUrl);
|
||||
|
||||
// 명령 다시 선택: 명령 목록 popup으로 돌아가고 이후 Back은 남은 history를 소비하지 않는다.
|
||||
overlay = await openFireAttack(4);
|
||||
await overlay.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||
const listPicker = page.getByTestId('command-picker');
|
||||
await expect(listPicker).toBeVisible();
|
||||
await expect(listPicker).not.toHaveAttribute('role', 'dialog');
|
||||
await expect(listPicker.getByRole('button', { name: /화계/ })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||||
|
||||
// 입력 완료 뒤에도 같은 URL과 원래 history 길이를 유지하고 payload·turnList는 그대로다.
|
||||
await listPicker.getByRole('button', { name: /화계/ }).click();
|
||||
overlay = page.getByRole('dialog', { name: '화계 4턴 명령 입력' });
|
||||
await overlay.getByTestId('command-argument-form').locator('select').selectOption('2');
|
||||
await overlay.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||||
await expect(editor.locator('.action-column > div').nth(3)).toHaveText('【허창】에 화계실행');
|
||||
await expect
|
||||
.poll(() => JSON.stringify(requests))
|
||||
.toContain('"turnList":[3],"action":"che_화계","args":{"destCityId":2}');
|
||||
await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1);
|
||||
expect(page.url()).toBe(mainUrl);
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(mainUrl);
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`opens chief argument input as the same full-screen overlay with selected turns at ${viewport.width}px`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await install(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(gamePath('/chief-center'));
|
||||
const mainUrl = page.url();
|
||||
const editor = page.locator('[data-command-scope="nation"]');
|
||||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const listPicker = page.getByTestId('command-picker');
|
||||
await listPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await listPicker.getByRole('button', { name: /발령/ }).click();
|
||||
const overlay = page.getByRole('dialog', { name: '발령 1턴 명령 입력' });
|
||||
await expect(overlay).toBeVisible();
|
||||
await expect(overlay.getByTestId('command-input-top-bar').locator('h2')).toHaveText('발령1턴');
|
||||
const geometry = await overlay.evaluate((element) => ({
|
||||
parentIsBody: element.parentElement === document.body,
|
||||
overlay: element.getBoundingClientRect().toJSON(),
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
}));
|
||||
expect(geometry.parentIsBody).toBe(true);
|
||||
expect(geometry.overlay).toMatchObject({ x: 0, y: 0, ...viewport });
|
||||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||||
await expect(overlay.getByTestId('command-argument-map')).toBeVisible();
|
||||
await verifyArgumentActionPlacement(page, overlay);
|
||||
// Ref che_발령.vue는 distanceList를 받지만 표시하지 않는다.
|
||||
await expect(overlay.getByTestId('city-distance-list')).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath(`chief-argument-overlay-${viewport.width}.png`) });
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||||
expect(page.url()).toBe(mainUrl);
|
||||
});
|
||||
}
|
||||
|
||||
// Ref ProcessCity + CitiesBasedOnDistance: 화계 계열·강행·첩보는 3칸, 이동·출병은 1칸까지 현재 도시 기준
|
||||
// 거리별 도시를 magenta/orange/yellow 밑줄 글자로 보여 주고, 누르면 대상 도시로 고른다.
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
] as const) {
|
||||
test(`lists Ref-style cities by distance for city target commands at ${viewport.width}px`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(gamePath('/'));
|
||||
const editor = page.locator('[data-command-scope="general"]:visible');
|
||||
const openCommand = async (turn: number, category: string, name: string) => {
|
||||
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
|
||||
const listPicker = page.getByTestId('command-picker');
|
||||
await listPicker.getByRole('button', { name: category, exact: true }).click();
|
||||
await listPicker.getByRole('button', { name, exact: true }).click();
|
||||
const overlay = page.getByRole('dialog', { name: `${name} ${turn}턴 명령 입력` });
|
||||
await expect(overlay).toBeVisible();
|
||||
return overlay;
|
||||
};
|
||||
|
||||
let overlay = await openCommand(1, '계략', '화계');
|
||||
const list = overlay.getByTestId('city-distance-list');
|
||||
await expect(list).toBeVisible();
|
||||
await expect(list.locator('.city-distance-row')).toHaveText([
|
||||
'1칸 떨어진 도시: 허창',
|
||||
'2칸 떨어진 도시:',
|
||||
'3칸 떨어진 도시:',
|
||||
]);
|
||||
const link = list.getByRole('button', { name: '허창', exact: true });
|
||||
const style = await link.evaluate((element) => {
|
||||
const computed = getComputedStyle(element);
|
||||
return {
|
||||
color: computed.color,
|
||||
textDecorationLine: computed.textDecorationLine,
|
||||
fontWeight: computed.fontWeight,
|
||||
};
|
||||
});
|
||||
expect(style).toEqual({ color: 'rgb(255, 0, 255)', textDecorationLine: 'underline', fontWeight: '400' });
|
||||
await expect(link).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await link.click();
|
||||
await expect(link).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(overlay.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
|
||||
await expect(overlay.getByTestId('command-map-selection-status')).toContainText('허창');
|
||||
const geometry = await overlay.evaluate((element) => {
|
||||
const fields = element.querySelector<HTMLElement>('.argument-fields')!.getBoundingClientRect();
|
||||
const distance = element.querySelector<HTMLElement>('[data-testid="city-distance-list"]')!;
|
||||
const rect = distance.getBoundingClientRect();
|
||||
return {
|
||||
fields: fields.toJSON(),
|
||||
list: rect.toJSON(),
|
||||
fontSize: getComputedStyle(distance).fontSize,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
};
|
||||
});
|
||||
await writeFile(testInfo.outputPath(`city-distance-${viewport.width}.json`), JSON.stringify(geometry, null, 2));
|
||||
await page.screenshot({ path: testInfo.outputPath(`city-distance-${viewport.width}.png`) });
|
||||
expect(geometry.fontSize).toBe('14px');
|
||||
expect(geometry.list.x).toBeGreaterThanOrEqual(geometry.fields.x);
|
||||
expect(geometry.list.right).toBeLessThanOrEqual(geometry.fields.right);
|
||||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||||
|
||||
await overlay.getByRole('button', { name: '화계 입력', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => JSON.stringify(requests))
|
||||
.toContain('"turnList":[0],"action":"che_화계","args":{"destCityId":2}');
|
||||
|
||||
overlay = await openCommand(2, '군사', '출병');
|
||||
await expect(overlay.getByTestId('city-distance-list').locator('.city-distance-row')).toHaveText([
|
||||
'1칸 떨어진 도시: 허창',
|
||||
]);
|
||||
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`groups recruitment targets by nation color without cross-nation resources at ${width}px`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const targets = buildRefGeneralTargetOptions({
|
||||
actorId: 1,
|
||||
actorNationId: 1,
|
||||
generals: [
|
||||
{ id: 1, name: '장수', nationId: 1, cityId: 1, npcState: 0, officerLevel: 5 },
|
||||
{
|
||||
id: 4,
|
||||
name: '적장',
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
npcState: 0,
|
||||
officerLevel: 1,
|
||||
gold: 9100,
|
||||
rice: 8200,
|
||||
crew: 7300,
|
||||
train: 90,
|
||||
atmos: 80,
|
||||
troopId: 4,
|
||||
leadership: 75,
|
||||
strength: 60,
|
||||
intel: 45,
|
||||
},
|
||||
{ id: 5, name: '떠돌이', nationId: 0, cityId: 3, npcState: 0, officerLevel: 0, gold: 600 },
|
||||
{ id: 6, name: '빙의장', nationId: 2, cityId: 2, npcState: 1, officerLevel: 1 },
|
||||
{ id: 7, name: '아군', nationId: 1, cityId: 1, npcState: 0, officerLevel: 1 },
|
||||
],
|
||||
nationNames: new Map([
|
||||
[1, '아국'],
|
||||
[2, '적국'],
|
||||
]),
|
||||
cityNames: new Map([
|
||||
[1, '업'],
|
||||
[2, '허창'],
|
||||
[3, '단양'],
|
||||
]),
|
||||
troopNames: new Map([[4, '비밀부대']]),
|
||||
});
|
||||
const requests = await install(page, false, {
|
||||
...commandTable,
|
||||
general: [{ category: '인사', values: [buildGeneralCommand('che_등용', '등용')] }],
|
||||
inputOptions: { ...inputOptions, ...targets },
|
||||
});
|
||||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
|
||||
await page.goto(gamePath('/'));
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /등용/ }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const list = form.getByTestId('general-target-list');
|
||||
const headers = list.getByTestId('general-nation-group');
|
||||
// Ref ORDER BY npc,name 순서에서 국가가 처음 나타난 순서로 묶는다.
|
||||
await expect(headers).toHaveText(['적국', '재야', '아국']);
|
||||
// 같은 국가 장수는 한 묶음에 모이므로 빙의장(npc=1)이 적국 묶음 안에 들어간다.
|
||||
await expect(list.locator('.target-option strong')).toHaveText(['적장', '빙의장', '떠돌이', '아군']);
|
||||
await expect(form.locator('#command-arg-destGeneralId optgroup')).toHaveCount(3);
|
||||
await expect(form.locator('#command-arg-destGeneralId optgroup').first()).toHaveAttribute('label', '적국');
|
||||
await expect(form.locator('#command-arg-destGeneralId option').first()).toHaveText('적장 (적국)');
|
||||
const text = await list.innerText();
|
||||
for (const hidden of ['9,100', '8,200', '7,300', '허창', '단양', '비밀부대', '훈련', '사기']) {
|
||||
expect(text).not.toContain(hidden);
|
||||
}
|
||||
await expect(list.locator('.target-option').first()).toContainText('통솔 75 · 무력 60 · 지력 45');
|
||||
const style = await headers.evaluateAll((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const computed = getComputedStyle(node);
|
||||
return [computed.backgroundColor, computed.color, computed.position];
|
||||
})
|
||||
);
|
||||
expect(style).toEqual([
|
||||
['rgb(128, 0, 0)', 'rgb(255, 255, 255)', 'sticky'],
|
||||
['rgb(0, 0, 0)', 'rgb(255, 255, 255)', 'sticky'],
|
||||
['rgb(0, 128, 0)', 'rgb(255, 255, 255)', 'sticky'],
|
||||
]);
|
||||
// 빙의장(npc=1)은 Ref getNPCColor처럼 skyblue다.
|
||||
await expect(list.locator('.target-option').nth(1).locator('strong')).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||||
await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-all-${width}.png`) });
|
||||
|
||||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||
await form.locator('#command-search-destGeneralId').fill('ㄸㄷ');
|
||||
await expect(headers).toHaveText(['재야']);
|
||||
await expect(list.locator('.target-option')).toHaveCount(1);
|
||||
await list.locator('.target-option').click();
|
||||
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('5');
|
||||
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await picker.evaluate((element) => ({
|
||||
url: location.href,
|
||||
viewport: [innerWidth, innerHeight],
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||||
list: element.querySelector('[data-testid=general-target-list]')?.getBoundingClientRect().toJSON(),
|
||||
headers: [...element.querySelectorAll('[data-testid=general-nation-group]')].map((node) =>
|
||||
node.getBoundingClientRect().toJSON()
|
||||
),
|
||||
}));
|
||||
// 메인 화면 자체는 Ref처럼 최소 500px 폭이라 390px에서 문서가 넓다. 전체화면 overlay 안만 확인한다.
|
||||
expect(geometry.overlayOverflow).toBe(0);
|
||||
await writeFile(testInfo.outputPath(`recruit-groups-${width}.json`), JSON.stringify(geometry, null, 2));
|
||||
await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-${width}.png`) });
|
||||
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_등용","args":{"destGeneralId":5}');
|
||||
});
|
||||
}
|
||||
|
||||
// 2-3 국가 대상: Ref che_임관·che_장수대상임관 권유문 목록, che_물자원조 작위별 한도표, che_피장파장 전략 재사용 대기.
|
||||
const nationTargetSources: NationTargetSource[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '아국',
|
||||
color: '#008000',
|
||||
capitalName: '업',
|
||||
level: 1,
|
||||
power: 1000,
|
||||
generalCount: 5,
|
||||
cityCount: 2,
|
||||
diplomacyState: 2,
|
||||
diplomacyTerm: 0,
|
||||
adjacent: false,
|
||||
scoutMessage: purifyNationHtml('<p>아국 권유문</p>'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#0000FF',
|
||||
capitalName: '허창',
|
||||
level: 2,
|
||||
power: 800,
|
||||
generalCount: 4,
|
||||
cityCount: 2,
|
||||
diplomacyState: 0,
|
||||
diplomacyTerm: 6,
|
||||
adjacent: true,
|
||||
// 저장·조회 경로와 같은 정제 결과만 화면에 전달한다. script와 on* 속성은 남지 않는다.
|
||||
scoutMessage: purifyNationHtml(
|
||||
'<div style="width: 870px; height: 300px; background-color: #334455"><b>천하를 함께</b>' +
|
||||
'<script>window.__scoutXss = 1</script><span onclick="window.__scoutXss = 2">함께</span></div>'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '오',
|
||||
color: '#FFFF00',
|
||||
capitalName: '건업',
|
||||
level: 2,
|
||||
power: 900,
|
||||
generalCount: 3,
|
||||
cityCount: 1,
|
||||
diplomacyState: 1,
|
||||
diplomacyTerm: 12,
|
||||
adjacent: false,
|
||||
blockScout: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`shows Ref scout messages for appointment commands at ${width}px`, async ({ page }, testInfo) => {
|
||||
const nationTargets = buildRefNationTargetOptions({
|
||||
actorNationId: 0,
|
||||
nations: nationTargetSources,
|
||||
join: { actorNpcState: 0, relYear: 5, openingPartYear: 3, initialNationGenLimit: 10 },
|
||||
});
|
||||
const requests = await install(page, false, {
|
||||
...commandTable,
|
||||
general: [
|
||||
{
|
||||
category: '인사',
|
||||
values: [
|
||||
{
|
||||
...buildNationCommand('che_임관', '임관'),
|
||||
},
|
||||
buildGeneralCommand('che_장수대상임관', '장수대상임관'),
|
||||
],
|
||||
},
|
||||
],
|
||||
inputOptions: {
|
||||
...inputOptions,
|
||||
nations: nationTargets.nations,
|
||||
nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets },
|
||||
nationScoutMessages: nationTargets.nationScoutMessages,
|
||||
},
|
||||
});
|
||||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
|
||||
await page.goto(gamePath('/'));
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^임관/ }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const list = form.getByTestId('nation-scout-list');
|
||||
const rows = list.getByTestId('nation-scout-row');
|
||||
await expect(list.locator('.nation-scout-header > div')).toHaveText(['국가명', '임관권유문']);
|
||||
await expect(rows.locator('.nation-scout-name')).toHaveText(['아국', '위', '오']);
|
||||
await expect(rows.nth(1).locator('.nation-scout-msg b')).toHaveText('천하를 함께');
|
||||
expect(await list.locator('script, [onclick]').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as unknown as { __scoutXss?: number }).__scoutXss)).toBeUndefined();
|
||||
// 국가명 칸은 국가색 배경이고 Ref isBrightColor 기준으로 글자색을 고른다.
|
||||
const nameStyles = await rows
|
||||
.locator('.nation-scout-name')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) => [getComputedStyle(node).backgroundColor, getComputedStyle(node).color])
|
||||
);
|
||||
expect(nameStyles).toEqual([
|
||||
['rgb(0, 128, 0)', 'rgb(255, 255, 255)'],
|
||||
['rgb(0, 0, 255)', 'rgb(255, 255, 255)'],
|
||||
['rgb(255, 255, 0)', 'rgb(0, 0, 0)'],
|
||||
]);
|
||||
// 임관 금지 국가는 대상 카드에서 현재 불가로 남는다.
|
||||
await expect(form.getByTestId('nation-target-list').locator('.target-option').nth(2)).toContainText(
|
||||
'임관이 금지되어 있습니다.'
|
||||
);
|
||||
|
||||
const toggle = list.getByRole('button', { name: /보기$/ });
|
||||
const plate = rows.nth(1).locator('.nation-scout-plate');
|
||||
if (width >= 940) {
|
||||
// Ref media-1000px(940px 이상): 130px + 870px 두 열, 크게/작게 보기 버튼 없음.
|
||||
await expect(toggle).toBeHidden();
|
||||
await expect(rows.nth(1)).toHaveCSS('grid-template-columns', '130px 870px');
|
||||
} else {
|
||||
await expect(toggle).toHaveText('작게 보기');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(plate).toHaveCSS('max-height', '200px');
|
||||
await expect(plate).toHaveCSS('overflow-x', 'auto');
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveText('크게 보기');
|
||||
await expect(rows.nth(1)).toHaveClass(/on-fit/);
|
||||
}
|
||||
await rows.nth(1).click();
|
||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||
await expect(rows.nth(1)).toHaveClass(/selected/);
|
||||
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await picker.evaluate((element) => {
|
||||
const row = element.querySelectorAll<HTMLElement>('[data-testid=nation-scout-row]')[1]!;
|
||||
const message = row.querySelector<HTMLElement>('.nation-scout-msg')!;
|
||||
return {
|
||||
url: location.href,
|
||||
viewport: [innerWidth, innerHeight],
|
||||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||||
list: element.querySelector('[data-testid=nation-scout-list]')?.getBoundingClientRect().toJSON(),
|
||||
row: row.getBoundingClientRect().toJSON(),
|
||||
plate: row.querySelector('.nation-scout-plate')!.getBoundingClientRect().toJSON(),
|
||||
message: message.getBoundingClientRect().toJSON(),
|
||||
transform: getComputedStyle(message).transform,
|
||||
};
|
||||
});
|
||||
expect(geometry.overlayOverflow).toBe(0);
|
||||
if (width < 940) {
|
||||
// on-fit: 870px 권유문을 목록 폭/870으로 줄이고, 높이는 200px × 같은 비율까지만 보인다.
|
||||
const scale = geometry.list!.width / 870;
|
||||
expect(geometry.message.width).toBeCloseTo(870 * scale, 0);
|
||||
expect(geometry.plate.height).toBeLessThanOrEqual(200 * scale + 1);
|
||||
expect(geometry.plate.right).toBeLessThanOrEqual(width);
|
||||
} else {
|
||||
expect(geometry.plate.width).toBeCloseTo(870, 0);
|
||||
expect(geometry.transform).toBe('none');
|
||||
}
|
||||
await writeFile(testInfo.outputPath(`scout-list-${width}.json`), JSON.stringify(geometry, null, 2));
|
||||
await picker.screenshot({ path: testInfo.outputPath(`scout-list-${width}.png`) });
|
||||
await picker.getByRole('button', { name: '임관 입력', exact: true }).click();
|
||||
await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_임관","args":{"destNationId":2}');
|
||||
|
||||
// 장수대상임관은 재야를 앞에 둔 같은 목록을 보이며 행을 눌러도 국가를 고르지 않는다.
|
||||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: /장수대상임관/ }).click();
|
||||
await expect(rows.locator('.nation-scout-name')).toHaveText(['재야', '아국', '위', '오']);
|
||||
await expect(rows.first().locator('.nation-scout-name')).toHaveCSS('background-color', 'rgb(0, 0, 0)');
|
||||
await expect(rows.first().locator('.nation-scout-name button')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`shows Ref aid limits and counter-strategy cooldowns at ${width}px`, async ({ page }, testInfo) => {
|
||||
const nationTargets = buildRefNationTargetOptions({ actorNationId: 1, nations: nationTargetSources });
|
||||
const counterStrategy = {
|
||||
...buildNationCommand('che_피장파장', '피장파장'),
|
||||
inputFields: [
|
||||
...buildNationCommand('che_피장파장', '피장파장').inputFields,
|
||||
{
|
||||
key: 'commandType',
|
||||
label: '전략',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'che_필사즉생', label: '필사즉생' },
|
||||
{ value: 'che_수몰', label: '수몰' },
|
||||
{ value: 'che_허보', label: '허보' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const aid = commandTable.nation.flatMap((group) => group.values).find((entry) => entry.key === 'che_물자원조')!;
|
||||
const requests = await install(page, false, {
|
||||
...commandTable,
|
||||
nation: [{ category: '전략', values: [counterStrategy, aid] }],
|
||||
inputOptions: {
|
||||
...inputOptions,
|
||||
nations: nationTargets.nations,
|
||||
nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets },
|
||||
strategyCooldowns: { che_수몰: 12 },
|
||||
context: { ...inputOptions.context, nationLevel: 3 },
|
||||
},
|
||||
});
|
||||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
|
||||
await page.goto(gamePath('/chief-center'));
|
||||
const editor = page.locator('[data-command-scope="nation"]');
|
||||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /피장파장/ }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const strategy = form.locator('#command-arg-commandType');
|
||||
await expect(strategy.locator('option')).toHaveText(['필사즉생', '수몰 (불가, 12턴)', '허보']);
|
||||
await expect(strategy.locator('option').nth(1)).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
await strategy.selectOption('che_수몰');
|
||||
await expect(strategy).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
await strategy.selectOption('che_허보');
|
||||
await expect(strategy).not.toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
// 선포·전쟁 중인 국가만 가능하고 아국은 불가다.
|
||||
const cards = form.getByTestId('nation-target-list').locator('.target-option');
|
||||
await expect(cards.locator('strong')).toHaveText(['오', '위', '아국']);
|
||||
await expect(cards.nth(2)).toHaveClass(/unavailable/);
|
||||
await expect(form.getByTestId('command-argument-guidance')).toContainText('60턴 동안');
|
||||
await form.locator('#command-arg-destNationId').selectOption('2');
|
||||
await picker.screenshot({ path: testInfo.outputPath(`counter-strategy-${width}.png`) });
|
||||
await picker.getByRole('button', { name: '피장파장 입력', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => JSON.stringify(requests))
|
||||
.toContain('"action":"che_피장파장","args":{"destNationId":2,"commandType":"che_허보"}');
|
||||
|
||||
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /원조/ }).click();
|
||||
const table = picker.getByTestId('aid-level-table');
|
||||
await expect(table.locator('li')).toHaveText([
|
||||
'방랑군: 0',
|
||||
'호족: 10,000',
|
||||
'군벌: 20,000',
|
||||
'주자사: 30,000',
|
||||
'주목: 40,000',
|
||||
'공: 50,000',
|
||||
'왕: 60,000',
|
||||
'황제: 70,000',
|
||||
]);
|
||||
await expect(picker.getByTestId('command-resource-summary')).toContainText('국가 작위 주자사');
|
||||
const current = table.locator('li.current .aid-level-name');
|
||||
await expect(current).toHaveText('주자사');
|
||||
await expect(current).toHaveCSS('font-weight', '700');
|
||||
await expect(current).toHaveCSS('text-decoration-line', 'underline');
|
||||
const geometry = await picker.evaluate((element) => ({
|
||||
url: location.href,
|
||||
viewport: [innerWidth, innerHeight],
|
||||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||||
table: element.querySelector('[data-testid=aid-level-table]')?.getBoundingClientRect().toJSON(),
|
||||
nameWidths: [...element.querySelectorAll('.aid-level-name')].map(
|
||||
(node) => node.getBoundingClientRect().width
|
||||
),
|
||||
fontSize: getComputedStyle(element.querySelector('[data-testid=aid-level-table]')!).fontSize,
|
||||
}));
|
||||
expect(geometry.overlayOverflow).toBe(0);
|
||||
expect(geometry.fontSize).toBe('14px');
|
||||
// Ref: width 4em inline-block.
|
||||
expect(new Set(geometry.nameWidths)).toEqual(new Set([56]));
|
||||
await writeFile(testInfo.outputPath(`aid-levels-${width}.json`), JSON.stringify(geometry, null, 2));
|
||||
await picker.screenshot({ path: testInfo.outputPath(`aid-levels-${width}.png`) });
|
||||
});
|
||||
|
||||
test(`shows Ref equipment, dex conversion and nation type details at ${width}px`, async ({ page }, testInfo) => {
|
||||
const requests = await install(page, false, {
|
||||
...commandTable,
|
||||
general: [
|
||||
{
|
||||
category: '개인',
|
||||
values: [
|
||||
{
|
||||
key: 'che_장비매매',
|
||||
name: '장비매매',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
{
|
||||
key: 'itemType',
|
||||
label: '장비 종류',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'horse', label: '명마' },
|
||||
{ value: 'weapon', label: '무기' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'itemCode',
|
||||
label: '장비',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'items',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'che_숙련전환',
|
||||
name: '숙련전환',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
{
|
||||
key: 'srcArmType',
|
||||
label: '감소 대상 숙련',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'armTypes',
|
||||
},
|
||||
{
|
||||
key: 'destArmType',
|
||||
label: '전환 대상 숙련',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'armTypes',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'che_건국',
|
||||
name: '건국',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
{ key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 },
|
||||
{
|
||||
key: 'nationType',
|
||||
label: '국가 성향',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'nationTypes',
|
||||
},
|
||||
{
|
||||
key: 'colorType',
|
||||
label: '국기 색상',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'colors',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
inputOptions: {
|
||||
...inputOptions,
|
||||
armTypes: [
|
||||
{ value: 1, label: '보병' },
|
||||
{ value: 2, label: '궁병' },
|
||||
{ value: 3, label: '기병' },
|
||||
],
|
||||
nationTypes: [
|
||||
{ value: 'che_도적', label: '도적', description: '계략↑ 금수입↓ 치안↓ 민심↓' },
|
||||
{ value: 'che_덕가', label: '덕가', description: '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓' },
|
||||
{ value: 'che_묵가', label: '묵가', description: '수성↑ 기술↓' },
|
||||
],
|
||||
items: {
|
||||
horse: [
|
||||
{ value: 'None', label: '노기 판매', description: '소유 물품 판매 · 판매가 500금' },
|
||||
{
|
||||
value: 'che_명마_갈색마',
|
||||
label: '갈색마',
|
||||
availableNow: true,
|
||||
description: '현재 구입 가능 · 가격 1,000 · 통솔 +1',
|
||||
},
|
||||
{
|
||||
value: 'che_명마_적토마',
|
||||
label: '적토마',
|
||||
availableNow: false,
|
||||
description: '현재 구입 불가: 치안 5,000 필요 · 가격 6,000 · 통솔 +6',
|
||||
},
|
||||
],
|
||||
weapon: [
|
||||
{
|
||||
value: 'None',
|
||||
label: '무기 판매',
|
||||
availableNow: false,
|
||||
description: '현재 보유한 장비가 없습니다.',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {
|
||||
...inputOptions.context,
|
||||
citySecurity: 2_000,
|
||||
dexterity: { '1': 1001, '2': 3000, '3': 500_000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
|
||||
await page.goto(gamePath('/'));
|
||||
const picker = page.getByTestId('command-picker');
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
|
||||
// 장비매매: Ref처럼 불가 항목은 붉은색 `(불가)`이고 치안·자금을 함께 보인다.
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: /장비매매/ }).click();
|
||||
await expect(form.getByTestId('command-argument-guidance')).toContainText(
|
||||
'현재 구입 불가능한 것은 붉은색으로 표시됩니다.'
|
||||
);
|
||||
await expect(form.getByTestId('command-resource-summary')).toContainText('현재 도시 치안 2,000');
|
||||
await expect(form.getByTestId('command-resource-summary')).toContainText('현재 자금');
|
||||
const itemType = form.locator('#command-arg-itemType');
|
||||
const itemCode = form.locator('#command-arg-itemCode');
|
||||
await itemType.selectOption('horse');
|
||||
await expect(itemCode.locator('option')).toHaveText(['노기 판매', '갈색마', '적토마 (불가)']);
|
||||
await expect(itemCode.locator('option').nth(2)).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
await expect(itemCode.locator('option').nth(1)).not.toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
await itemCode.selectOption('che_명마_적토마');
|
||||
await expect(itemCode).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
await expect(form.locator('.option-detail')).toContainText('치안 5,000 필요');
|
||||
await itemType.selectOption('weapon');
|
||||
await expect(itemCode.locator('option')).toHaveText(['무기 판매 (불가)']);
|
||||
await itemType.selectOption('horse');
|
||||
await itemCode.selectOption('che_명마_갈색마');
|
||||
await picker.screenshot({ path: testInfo.outputPath(`equipment-${width}.png`) });
|
||||
await picker.getByRole('button', { name: '장비매매 입력', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => JSON.stringify(requests))
|
||||
.toContain('"action":"che_장비매매","args":{"itemType":"horse","itemCode":"che_명마_갈색마"}');
|
||||
|
||||
// 숙련전환: Ref 라벨·`병과 (등급)`과 감소/전환 대상 전후 두 줄.
|
||||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: /숙련전환/ }).click();
|
||||
const src = form.getByLabel('감소 대상 숙련');
|
||||
const dest = form.getByLabel('전환 대상 숙련');
|
||||
await expect(src.locator('option')).toHaveText(['보병 (F)', '궁병 (F+)', '기병 (S-)']);
|
||||
await expect(form.getByTestId('dex-conversion-same')).toBeVisible();
|
||||
await src.selectOption('3');
|
||||
await dest.selectOption('2');
|
||||
const preview = form.getByTestId('dex-conversion-preview');
|
||||
const previewRows = preview.locator('.dex-conversion-row');
|
||||
// cut = trunc(500,000 × 0.4) = 200,000, add = trunc(200,000 × 0.9) = 180,000.
|
||||
await expect(previewRows.nth(0).locator('> div')).toHaveText([
|
||||
'기병',
|
||||
'[',
|
||||
'S-',
|
||||
'500,000',
|
||||
']',
|
||||
'→',
|
||||
'[',
|
||||
'A-',
|
||||
'300,000',
|
||||
']',
|
||||
]);
|
||||
await expect(previewRows.nth(1).locator('> div')).toHaveText([
|
||||
'궁병',
|
||||
'[',
|
||||
'F+',
|
||||
'3,000',
|
||||
']',
|
||||
'→',
|
||||
'[',
|
||||
'B',
|
||||
'183,000',
|
||||
']',
|
||||
]);
|
||||
await expect(previewRows.nth(0).locator('> div').nth(2)).toHaveCSS('color', 'rgb(255, 99, 71)');
|
||||
await expect(previewRows.nth(0).locator('> div').nth(7)).toHaveCSS('color', 'rgb(255, 140, 0)');
|
||||
await expect(previewRows.nth(1).locator('> div').nth(2)).toHaveCSS('color', 'rgb(0, 0, 128)');
|
||||
await expect(previewRows.nth(1).locator('> div').nth(7)).toHaveCSS('color', 'rgb(50, 205, 50)');
|
||||
const dexGeometry = await picker.evaluate((element) => ({
|
||||
url: location.href,
|
||||
viewport: [innerWidth, innerHeight],
|
||||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||||
rows: [...element.querySelectorAll('.dex-conversion-row')].map((row) => ({
|
||||
columns: getComputedStyle(row).gridTemplateColumns,
|
||||
cells: [...row.children].map((cell) => {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
return {
|
||||
text: cell.textContent?.trim(),
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
scroll: cell.scrollWidth,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
expect(dexGeometry.overlayOverflow).toBe(0);
|
||||
for (const row of dexGeometry.rows) {
|
||||
// 병과명 4em(56px) 칸 안에 이름이 들어가고 칸끼리 겹치지 않는다.
|
||||
expect(row.columns.split(' ')[0]).toBe('56px');
|
||||
for (let index = 1; index < row.cells.length; index += 1) {
|
||||
expect(row.cells[index]!.left).toBeGreaterThanOrEqual(row.cells[index - 1]!.right - 0.5);
|
||||
}
|
||||
}
|
||||
await writeFile(testInfo.outputPath(`dex-conversion-${width}.json`), JSON.stringify(dexGeometry, null, 2));
|
||||
await picker.screenshot({ path: testInfo.outputPath(`dex-conversion-${width}.png`) });
|
||||
await picker.getByRole('button', { name: '숙련전환 입력', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => JSON.stringify(requests))
|
||||
.toContain('"action":"che_숙련전환","args":{"srcArmType":3,"destArmType":2}');
|
||||
|
||||
// 건국: Ref 성향표(`- 이름 : 장점, 단점`)를 입력 위에 보이고, 행을 누르면 성향을 고른다(Core 보조).
|
||||
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: /^건국/ }).click();
|
||||
const typeList = form.getByTestId('nation-type-list');
|
||||
const typeRows = typeList.locator('li');
|
||||
await expect(typeRows).toHaveText([
|
||||
/- 도적\s*: 계략↑,\s*금수입↓ 치안↓ 민심↓/,
|
||||
/- 덕가\s*: 치안↑ 인구↑ 민심↑,\s*쌀수입↓ 수성↓/,
|
||||
/- 묵가\s*: 수성↑,\s*기술↓/,
|
||||
]);
|
||||
await expect(typeRows.first().locator('.nation-type-pros')).toHaveCSS('color', 'rgb(0, 255, 255)');
|
||||
await expect(typeRows.first().locator('.nation-type-cons')).toHaveCSS('color', 'rgb(255, 0, 255)');
|
||||
await expect(typeRows.first()).toHaveClass(/selected/);
|
||||
await typeRows.nth(1).locator('.nation-type-name').click();
|
||||
await expect(form.getByLabel('국가 성향')).toHaveValue('che_덕가');
|
||||
await expect(typeRows.nth(1).locator('.nation-type-name')).toHaveAttribute('aria-pressed', 'true');
|
||||
const typeGeometry = await picker.evaluate((element) => {
|
||||
const list = element.querySelector('[data-testid=nation-type-list]')!;
|
||||
const row = list.querySelector('li')!;
|
||||
return {
|
||||
url: location.href,
|
||||
viewport: [innerWidth, innerHeight],
|
||||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||||
list: list.getBoundingClientRect().toJSON(),
|
||||
rowWidth: row.getBoundingClientRect().width,
|
||||
rowHeights: [...list.querySelectorAll('li')].map((node) => node.getBoundingClientRect().height),
|
||||
lineHeight: Number.parseFloat(getComputedStyle(list).lineHeight),
|
||||
columns: getComputedStyle(row).gridTemplateColumns.split(' ').map(Number.parseFloat),
|
||||
listBeforeFields:
|
||||
list.compareDocumentPosition(element.querySelector('#command-arg-nationType')!) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
};
|
||||
});
|
||||
expect(typeGeometry.overlayOverflow).toBe(0);
|
||||
expect(typeGeometry.listBeforeFields).toBeTruthy();
|
||||
if (width >= 992) {
|
||||
// Ref bootstrap: lg(992px) 이상 col-lg-1/2/2.
|
||||
const [first, second, third] = typeGeometry.columns;
|
||||
const unit = typeGeometry.rowWidth / 12;
|
||||
expect(first).toBeCloseTo(unit, 0);
|
||||
expect(second).toBeCloseTo(unit * 2, 0);
|
||||
expect(third).toBeCloseTo(unit * 2, 0);
|
||||
}
|
||||
// 390px은 Ref 최소 폭보다 좁아 내용 폭 열을 쓴다. 어느 폭에서도 한 성향이 한 줄이다.
|
||||
for (const height of typeGeometry.rowHeights) {
|
||||
expect(height).toBeLessThan(typeGeometry.lineHeight * 1.5);
|
||||
}
|
||||
await writeFile(testInfo.outputPath(`nation-types-${width}.json`), JSON.stringify(typeGeometry, null, 2));
|
||||
await picker.screenshot({ path: testInfo.outputPath(`nation-types-${width}.png`) });
|
||||
await form.getByLabel('국가명').fill('신국');
|
||||
await picker.getByRole('button', { name: '건국 입력', exact: true }).click();
|
||||
await expect.poll(() => JSON.stringify(requests)).toContain('"nationType":"che_덕가"');
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`징병 현황 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
||||
await verifyVerticalAlignment(page, testInfo, ['.recruitment-status > span']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ test('reserves every requested general and chief command through Chromium and re
|
||||
fill?: (form: Locator) => Promise<void>
|
||||
) => {
|
||||
await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click();
|
||||
const picker = editor.getByTestId('command-picker');
|
||||
// 명령 목록은 편집기 안 popup이고, 인자 입력은 body 위 전체화면 overlay로 옮겨진다.
|
||||
const picker = editor.page().getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${category}$`) }).click();
|
||||
const commandButton = picker.getByRole('button', { name: command }).first();
|
||||
await expect(commandButton).toBeEnabled();
|
||||
@@ -78,7 +79,7 @@ test('reserves every requested general and chief command through Chromium and re
|
||||
if (fill) {
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
await fill(form);
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
}
|
||||
await expect(editor.locator('.action-column > div').nth(turn)).toHaveText(command);
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ const reserveFirstTurn = async (page: Page, commandName: string, targetName: str
|
||||
const targetValue = await targetOption.getAttribute('value');
|
||||
if (!targetValue) throw new Error(`Missing target value for ${targetName}.`);
|
||||
await picker.locator('select').selectOption(targetValue);
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
await expect(picker).toBeHidden();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
@@ -161,3 +162,12 @@ test('keeps diplomacy detail redacted below permission three', async ({ page })
|
||||
await expect(page.getByText('문서 작성 권한은 군주/수뇌에게만 제공됩니다.')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '전송' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`외교 제목 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await installFixture(page, 4);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto('diplomacy');
|
||||
await verifyVerticalAlignment(page, testInfo, ['.panel-header']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2659,6 +2659,40 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nation.getCityOfficeRequests": {
|
||||
"result": {
|
||||
"data": {
|
||||
"canManage": true,
|
||||
"generalId": 1,
|
||||
"nationId": 1,
|
||||
"serverId": "typography-fixture",
|
||||
"city": {
|
||||
"id": 1,
|
||||
"name": "허창"
|
||||
},
|
||||
"options": [],
|
||||
"requests": [
|
||||
{
|
||||
"id": "typography-request",
|
||||
"generalId": 3,
|
||||
"generalName": "가나다라마바사아자",
|
||||
"cityId": 1,
|
||||
"cityName": "허창",
|
||||
"officerLevel": 4,
|
||||
"status": "pending",
|
||||
"defaultDecision": "approve",
|
||||
"defaultReason": "vacant",
|
||||
"dueYear": 190,
|
||||
"dueMonth": 2,
|
||||
"remainingSeconds": 540,
|
||||
"invalidReason": null
|
||||
}
|
||||
],
|
||||
"running": true,
|
||||
"tickSeconds": 600
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"possession": {
|
||||
@@ -2910,5 +2944,126 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"best": {
|
||||
"lobby.info": {
|
||||
"result": {
|
||||
"data": {
|
||||
"year": 197,
|
||||
"month": 7,
|
||||
"userCnt": 17,
|
||||
"npcCnt": 31,
|
||||
"nationCnt": 5,
|
||||
"maxUserCnt": 50,
|
||||
"turnTerm": 60,
|
||||
"fictionMode": "가상",
|
||||
"otherTextInfo": "표준",
|
||||
"starttime": "0190-01-01T00:00:00.000Z",
|
||||
"myGeneral": {
|
||||
"id": 1,
|
||||
"name": "가나다라마바사아자",
|
||||
"troopId": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.status": {
|
||||
"result": {
|
||||
"data": {
|
||||
"userId": "frontend-legacy-fixture-user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ranking.getBestGeneral": {
|
||||
"result": {
|
||||
"data": {
|
||||
"isUnited": true,
|
||||
"sections": [
|
||||
{
|
||||
"title": "명 성",
|
||||
"valueType": "int",
|
||||
"entries": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "가나다라마바사아자",
|
||||
"ownerName": "시각검증",
|
||||
"nationName": "대한민국천하통일국",
|
||||
"bgColor": "#006400",
|
||||
"fgColor": "#ffffff",
|
||||
"picture": "default.jpg",
|
||||
"imageServer": 0,
|
||||
"value": 12000,
|
||||
"printValue": "12,000"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "가나다라마바사아자",
|
||||
"ownerName": "검증계정",
|
||||
"nationName": "대한민국천하통일국",
|
||||
"bgColor": "#8b0000",
|
||||
"fgColor": "#ffffff",
|
||||
"picture": "default.jpg",
|
||||
"imageServer": 0,
|
||||
"value": 11000,
|
||||
"printValue": "11,000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "계 급",
|
||||
"valueType": "int",
|
||||
"entries": []
|
||||
}
|
||||
],
|
||||
"uniqueItems": [
|
||||
{
|
||||
"title": "명 마",
|
||||
"slot": "horse",
|
||||
"entries": [
|
||||
{
|
||||
"itemKey": "che_명마_15_적토마",
|
||||
"itemName": "적토마",
|
||||
"itemInfo": "최고의 명마",
|
||||
"owner": {
|
||||
"id": 1,
|
||||
"name": "가나다라마바사아자",
|
||||
"nationName": "대한민국천하통일국",
|
||||
"bgColor": "#006400",
|
||||
"fgColor": "#ffffff",
|
||||
"picture": "default.jpg",
|
||||
"imageServer": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"itemKey": "che_명마_15_적토마",
|
||||
"itemName": "적토마",
|
||||
"itemInfo": "최고의 명마",
|
||||
"owner": {
|
||||
"id": 0,
|
||||
"name": "가나다라마바사아자",
|
||||
"nationName": "대한민국천하통일국",
|
||||
"bgColor": "#00582c",
|
||||
"fgColor": "#ffffff",
|
||||
"picture": null,
|
||||
"imageServer": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"lobby.info": "result",
|
||||
"auth.status": {
|
||||
"result": {
|
||||
"data": {
|
||||
"ok": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"join.getConfig": "result"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||
@@ -591,11 +592,17 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
if (type === 'generalHistory') {
|
||||
return response({
|
||||
type,
|
||||
logs: Array.from({ length: 25 }, (_, index) => ({ id: 100 - index, text: `열전-${index}` })),
|
||||
logs: Array.from({ length: 25 }, (_, index) => ({
|
||||
id: 100 - index,
|
||||
text: `열전-${index}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (type === 'generalAction') {
|
||||
const firstPage = Array.from({ length: 24 }, (_, index) => ({ id: 50 - index, text: `행동-${index}` }));
|
||||
const firstPage = Array.from({ length: 24 }, (_, index) => ({
|
||||
id: 50 - index,
|
||||
text: `행동-${index}`,
|
||||
}));
|
||||
return response({
|
||||
type,
|
||||
logs: jsonInput.beforeId ? [{ id: 26, text: '이전 행동' }] : firstPage,
|
||||
@@ -1033,10 +1040,10 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상·
|
||||
expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0);
|
||||
expect(geometry.styles).toEqual({
|
||||
diamondColor: 'rgb(135, 206, 235)',
|
||||
namePlateFontSize: '10.5px',
|
||||
namePlateFontSize: '12px',
|
||||
nameCoverColor: 'rgb(255, 255, 0)',
|
||||
crewPlateColor: 'rgb(255, 69, 0)',
|
||||
crewPlateFontSize: '12.6px',
|
||||
crewPlateFontSize: '12px',
|
||||
defenseArrowColor: 'rgb(255, 0, 255)',
|
||||
});
|
||||
};
|
||||
@@ -1316,7 +1323,7 @@ test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자
|
||||
expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']);
|
||||
expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']);
|
||||
expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]);
|
||||
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']);
|
||||
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12px', '12px', '12px']);
|
||||
expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]);
|
||||
expect(measurements[0]?.nameFontSize).toBe('14px');
|
||||
expect(measurements[0]?.actionFontSize).toBe('14px');
|
||||
@@ -1386,7 +1393,12 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
|
||||
|
||||
test('내 정보 열전은 전체 표시 후 종료하고 행동 기록만 다음 페이지를 요청한다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permission: 'head', myset: 3, settingMutations: [], accessPages: [], pagedMyLogs: true, myLogInputs: [],
|
||||
permission: 'head',
|
||||
myset: 3,
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
pagedMyLogs: true,
|
||||
myLogInputs: [],
|
||||
};
|
||||
await install(page, state);
|
||||
await page.goto('my-page');
|
||||
@@ -1413,7 +1425,8 @@ test('내 정보 열전은 전체 표시 후 종료하고 행동 기록만 다
|
||||
await expect(action.getByRole('button', { name: '이전 로그 불러오기' })).toHaveCount(0);
|
||||
expect(state.myLogInputs?.filter((input) => input.type === 'generalHistory')).toHaveLength(1);
|
||||
expect(state.myLogInputs?.filter((input) => input.type === 'generalAction')).toEqual([
|
||||
{ type: 'generalAction' }, { type: 'generalAction', beforeId: 27 },
|
||||
{ type: 'generalAction' },
|
||||
{ type: 'generalAction', beforeId: 27 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2680,3 +2693,17 @@ test('감찰부 사관 연수를 채운 일반 장수는 개인 기록 거부에
|
||||
await expect(page.locator('[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록');
|
||||
}
|
||||
});
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`내 정보의 오른쪽 수치와 왼쪽 날짜 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await install(page, { permission: 'head', myset: 3, settingMutations: [], accessPages: [] });
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto('my-page');
|
||||
await verifyVerticalAlignment(page, testInfo, [
|
||||
'.battle-general-extra > span',
|
||||
'.battle-general-extra > strong',
|
||||
'.dex-row > span',
|
||||
'.dex-row > strong',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
@@ -156,7 +157,12 @@ const installArchiveViews = async (page: Page) => {
|
||||
});
|
||||
}
|
||||
if (operation === 'yearbook.getRange') {
|
||||
return response({ firstYearMonth: 22001, lastYearMonth: 22001, currentYearMonth: 22001 });
|
||||
return response({
|
||||
firstYearMonth: 22001,
|
||||
lastYearMonth: 22001,
|
||||
currentYearMonth: 22001,
|
||||
mapLayout: { mapName: 'miniche', cityList: [], regionMap: {}, levelMap: {} },
|
||||
});
|
||||
}
|
||||
if (operation === 'public.getMapLayout') {
|
||||
return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} });
|
||||
@@ -293,3 +299,48 @@ test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰
|
||||
await expect(labels.filter({ hasText: '명국' })).toHaveCSS('color', 'rgb(0, 0, 0)');
|
||||
await page.screenshot({ path: testInfo.outputPath('yearbook-nation-contrast-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('왕조의 역사 보기는 해당 기수의 지도 레이아웃으로 연감을 렌더한다', async ({ page }, testInfo) => {
|
||||
await installArchiveViews(page);
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto('dynasty');
|
||||
const archivedHistory = page.getByRole('link', { name: '역사 보기' }).last();
|
||||
await expect(archivedHistory).toHaveAttribute('href', /yearbook\?serverID=che-current-1$/);
|
||||
await archivedHistory.click();
|
||||
|
||||
await expect(page.locator('.history-grid')).toBeVisible();
|
||||
await expect(page.locator('.map-area')).toHaveClass(/map-theme-miniche/);
|
||||
await expect(page.locator('.map-bgroad')).toHaveCSS('background-image', /miniche_road\.png/);
|
||||
await expect(page.locator('.nation-position tbody tr')).toHaveCount(2);
|
||||
const mapEvidence = await page.locator('.map-area').evaluate((area) => {
|
||||
const road = area.querySelector<HTMLElement>('.map-bgroad');
|
||||
const background = area.querySelector<HTMLImageElement>('.map-background-image');
|
||||
return {
|
||||
mapClass: area.className,
|
||||
mapRect: area.getBoundingClientRect().toJSON(),
|
||||
backgroundRect: background?.getBoundingClientRect().toJSON(),
|
||||
backgroundNaturalSize: background
|
||||
? { width: background.naturalWidth, height: background.naturalHeight }
|
||||
: null,
|
||||
backgroundObjectFit: background ? getComputedStyle(background).objectFit : null,
|
||||
roadBackground: road ? getComputedStyle(road).backgroundImage : null,
|
||||
};
|
||||
});
|
||||
expect(mapEvidence.mapRect.width / mapEvidence.mapRect.height).toBeCloseTo(7 / 5, 2);
|
||||
expect(mapEvidence.backgroundNaturalSize?.width).toBeGreaterThan(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('dynasty-history-map-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 800 });
|
||||
await expect(page.locator('.map-area')).toHaveClass(/map-theme-miniche/);
|
||||
const mobileEvidence = await page.locator('.map-area').evaluate((area) => ({
|
||||
mapClass: area.className,
|
||||
mapRect: area.getBoundingClientRect().toJSON(),
|
||||
roadBackground: getComputedStyle(area.querySelector<HTMLElement>('.map-bgroad')!).backgroundImage,
|
||||
}));
|
||||
expect(mobileEvidence.mapRect.width / mobileEvidence.mapRect.height).toBeCloseTo(7 / 5, 2);
|
||||
await writeFile(
|
||||
testInfo.outputPath('dynasty-history-map-geometry.json'),
|
||||
JSON.stringify({ desktop: mapEvidence, mobile: mobileEvidence }, null, 2)
|
||||
);
|
||||
await page.screenshot({ path: testInfo.outputPath('dynasty-history-map-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ const operationNames = (route: Route) =>
|
||||
|
||||
type NavigationFixture = {
|
||||
pageExitAudit?: boolean;
|
||||
cityOfficeRequestIds?: string[];
|
||||
decoratedNpcChiefs?: boolean;
|
||||
officerLevel: number;
|
||||
permission: number;
|
||||
@@ -646,6 +647,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
|
||||
return response({
|
||||
myGeneral: { id: 7, name: '메뉴검증장수' },
|
||||
serverId: state.serverId ?? 'che_fixture_season',
|
||||
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
|
||||
profile: state.profile ?? 'che',
|
||||
gameIdx: state.gameIdx ?? 101,
|
||||
year: state.currentYear ?? 185,
|
||||
@@ -815,6 +817,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
|
||||
if (operation === 'general.getFrontStatus') {
|
||||
return response({
|
||||
serverId: state.serverId ?? 'che_fixture_season',
|
||||
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
|
||||
onlineUserCount: 1,
|
||||
onlineNations: '위(1)',
|
||||
onlineGenerals: '메뉴검증장수',
|
||||
@@ -877,56 +880,66 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
|
||||
|
||||
const installRealtimeHarness = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
class TestEventSource extends EventTarget {
|
||||
static latest: TestEventSource | null = null;
|
||||
static created = 0;
|
||||
static closed = 0;
|
||||
readonly url: string;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super();
|
||||
this.url = url.toString();
|
||||
TestEventSource.created += 1;
|
||||
TestEventSource.latest = this;
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('open')));
|
||||
}
|
||||
|
||||
close() {
|
||||
if (TestEventSource.latest === this) {
|
||||
TestEventSource.latest = null;
|
||||
TestEventSource.closed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource });
|
||||
Object.defineProperty(window, '__emitMainRealtime', {
|
||||
configurable: true,
|
||||
value: (type: string, payload: unknown) => {
|
||||
TestEventSource.latest?.dispatchEvent(
|
||||
new MessageEvent(type, {
|
||||
data: JSON.stringify({
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
let latest: { emit: (type: string, payload: unknown) => void; close: () => void } | null = null;
|
||||
let created = 0;
|
||||
let closed = 0;
|
||||
let authenticated = false;
|
||||
let credentialInUrl = false;
|
||||
window.fetch = (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
||||
if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
|
||||
if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
authenticated = new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false;
|
||||
credentialInUrl = url.searchParams.has('token');
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
let ended = false;
|
||||
const source = {
|
||||
close: () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
closed += 1;
|
||||
if (latest === source) latest = null;
|
||||
controller.close();
|
||||
},
|
||||
emit: (type: string, payload: unknown) => {
|
||||
if (ended) return;
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`event: ${type}\ndata: ${JSON.stringify({
|
||||
type,
|
||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||
? { refreshGrant: 'fixture-realtime-grant' }
|
||||
: {}),
|
||||
...((payload as object) ?? {}),
|
||||
}),
|
||||
})
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start: (next) => {
|
||||
controller = next;
|
||||
},
|
||||
cancel: () => {
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
closed += 1;
|
||||
if (latest === source) latest = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, '__hasMainRealtime', {
|
||||
configurable: true,
|
||||
value: () => TestEventSource.latest !== null,
|
||||
});
|
||||
Object.defineProperty(window, '__mainRealtimeStats', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
active: TestEventSource.latest !== null,
|
||||
created: TestEventSource.created,
|
||||
closed: TestEventSource.closed,
|
||||
}),
|
||||
created += 1;
|
||||
latest = source;
|
||||
init?.signal?.addEventListener('abort', source.close, { once: true });
|
||||
return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
|
||||
};
|
||||
Object.assign(window, {
|
||||
__emitMainRealtime: (type: string, payload: unknown) => {
|
||||
latest?.emit(type, payload);
|
||||
},
|
||||
__hasMainRealtime: () => latest !== null,
|
||||
__mainRealtimeStats: () => ({ active: latest !== null, created, closed, authenticated, credentialInUrl }),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -5283,7 +5296,7 @@ for (const viewport of [
|
||||
);
|
||||
await picker.screenshot({ path: test.info().outputPath(`equipment-order-${viewport.name}.png`) });
|
||||
const request = page.waitForRequest((entry) => entry.url().includes('turns.reserved.setGeneral'));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
expect(JSON.stringify((await request).postDataJSON())).toContain('"itemCode":"che_훈련_청주"');
|
||||
});
|
||||
|
||||
@@ -5351,7 +5364,7 @@ for (const viewport of [
|
||||
await writeFile(test.info().outputPath(`owned-sale-${viewport.name}.json`), JSON.stringify(geometry, null, 2));
|
||||
await picker.screenshot({ path: test.info().outputPath(`owned-sale-${viewport.name}.png`) });
|
||||
const request = page.waitForRequest((request) => request.url().includes('turns.reserved.setGeneralBulk'));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||||
const payload = (await request).postDataJSON();
|
||||
expect(JSON.stringify(payload)).toContain('"itemCode":"None"');
|
||||
expect(JSON.stringify(payload)).toContain('"itemType":"item"');
|
||||
@@ -5427,10 +5440,53 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__mainRealtimeStats: () => { authenticated: boolean; credentialInUrl: boolean };
|
||||
}
|
||||
).__mainRealtimeStats()
|
||||
)
|
||||
).toMatchObject({ authenticated: true, credentialInUrl: false });
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
|
||||
expect(state.dashboardGrantHeaders).toContain(null);
|
||||
|
||||
const captureRefreshLayout = async (phase: string): Promise<void> => {
|
||||
if (!autoRefreshArtifactRoot) return;
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const layout = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
|
||||
cards: Array.from(document.querySelectorAll('[data-main-target="general"], [data-main-target="city"]')).map(
|
||||
(element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
target: element.getAttribute('data-main-target'),
|
||||
bounds: element.getBoundingClientRect().toJSON(),
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
display: style.display,
|
||||
};
|
||||
}
|
||||
),
|
||||
images: Array.from(document.images).map((image) => ({
|
||||
src: image.getAttribute('src'),
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
complete: image.complete,
|
||||
objectFit: getComputedStyle(image).objectFit,
|
||||
})),
|
||||
}));
|
||||
await Promise.all([
|
||||
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-layout.json`), JSON.stringify(layout, null, 2)),
|
||||
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-dom.html`), await page.content()),
|
||||
]);
|
||||
};
|
||||
|
||||
const operationsBeforeTournament = state.operations.length;
|
||||
state.stage = 1;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
|
||||
@@ -5470,6 +5526,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
}).observe({ entryTypes: ['measure'] });
|
||||
});
|
||||
|
||||
await captureRefreshLayout('before-refresh');
|
||||
const callsBeforeRefresh = state.generalMeCalls;
|
||||
const operationsBeforeClockOnly = state.operations.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
@@ -5613,6 +5670,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)),
|
||||
};
|
||||
});
|
||||
await captureRefreshLayout('after-refresh');
|
||||
expect(profile.generalMounted).toBe(true);
|
||||
expect(profile.cityMounted).toBe(true);
|
||||
expect(profile.generalMutations).toBeGreaterThan(0);
|
||||
@@ -7135,3 +7193,53 @@ test.describe('page exit navigation', () => {
|
||||
expect(context.pages()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
for (const width of [1000, 390]) {
|
||||
test(`city office request toast announces new IDs without repeat at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 0,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
cityOfficeRequestIds: [],
|
||||
latestVote: null,
|
||||
};
|
||||
await installRealtimeHarness(page);
|
||||
await installFixture(page, state);
|
||||
await waitForMain(page);
|
||||
await waitForMainRealtime(page);
|
||||
await expect(page.locator('.office-request-notice')).toHaveCount(0);
|
||||
state.cityOfficeRequestIds = ['30:760'];
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
const toast = page.locator('.office-request-notice');
|
||||
await expect(toast).toContainText('새 관직 요청');
|
||||
const rect = await toast.boundingBox();
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
|
||||
await page.screenshot({ path: testInfo.outputPath(`office-request-toast-${width}.png`), fullPage: true });
|
||||
await writeFile(testInfo.outputPath(`office-request-toast-${width}.json`), JSON.stringify(rect));
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
await expect(toast).toBeVisible();
|
||||
await toast.getByRole('button', { name: '관직 요청 알림 닫기' }).click();
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
await expect(toast).toHaveCount(0);
|
||||
await expect(page.locator('.office-request-link')).toContainText('관직 요청 1건');
|
||||
await page.reload();
|
||||
await waitForMainRealtime(page);
|
||||
await expect(toast).toHaveCount(0);
|
||||
state.cityOfficeRequestIds = ['30:760', '31:760'];
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
await expect(toast).toContainText('대기 2건');
|
||||
await writeFile(
|
||||
testInfo.outputPath(`office-request-toast-${width}.html`),
|
||||
await toast.evaluate((element) => element.outerHTML)
|
||||
);
|
||||
await toast.getByRole('link').click();
|
||||
await expect(page).toHaveURL(/\/nation\/personnel#city-office-requests$/);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
@@ -115,3 +116,13 @@ test('keeps the nation-betting amount while changing candidates, submitting, and
|
||||
await expect(amount).toHaveValue('100');
|
||||
await page.screenshot({ path: testInfo.outputPath('nation-betting-amount-retained.png'), fullPage: true });
|
||||
});
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`국가 베팅 지급표 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await installFixture(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto('nation-betting');
|
||||
await page.getByRole('button', { name: /두 번째 천통국 베팅/u }).click();
|
||||
await verifyVerticalAlignment(page, testInfo, ['.payout-row > div']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { devices, expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
@@ -9,8 +10,11 @@ import { acceptAppConfirmation } from './appConfirmation.js';
|
||||
type Role = 'leader' | 'head' | 'member';
|
||||
type FixtureState = {
|
||||
role: Role;
|
||||
officeRequests?: Record<string, unknown>;
|
||||
officeMutation?: Record<string, unknown>;
|
||||
failNextRate?: boolean;
|
||||
failPersonnelLoad?: boolean;
|
||||
testIcons?: boolean;
|
||||
rate: number;
|
||||
appointedGeneralId?: number;
|
||||
appointedCityId?: number;
|
||||
@@ -98,8 +102,8 @@ const personnelFixture = (state: FixtureState) => {
|
||||
const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1;
|
||||
const fullGenerals = [
|
||||
general(1, '조조', 12),
|
||||
general(2, '순욱', 11),
|
||||
general(3, '하후돈', 4),
|
||||
general(2, '순욱', 11, state.testIcons ? { picture: 'transparent.png', imageServer: 1 } : {}),
|
||||
general(3, '하후돈', 4, state.testIcons ? { picture: 'missing.png', imageServer: 1 } : {}),
|
||||
general(4, '곽가', 3),
|
||||
general(5, '정욱', 2),
|
||||
general(6, '장료', 1, { npcState: 2 }),
|
||||
@@ -236,6 +240,24 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (operation === 'auth.status') return response({ ok: true });
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'nation.getCityOfficeRequests')
|
||||
return response(
|
||||
state.officeRequests ?? {
|
||||
canManage: state.role !== 'member',
|
||||
generalId: 6,
|
||||
nationId: 1,
|
||||
serverId: 'office-season',
|
||||
city: { id: 1, name: '허창' },
|
||||
options: [],
|
||||
requests: [],
|
||||
running: true,
|
||||
tickSeconds: 600,
|
||||
}
|
||||
);
|
||||
if (operation === 'nation.cityOfficeRequest') {
|
||||
state.officeMutation = jsonInput;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'nation.getPersonnelInfo') {
|
||||
return state.failPersonnelLoad
|
||||
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
|
||||
@@ -1022,3 +1044,194 @@ test('finance adopts the server-purified notice and scout message before renderi
|
||||
)
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
for (const width of [1365, 390]) {
|
||||
test(`personnel custom icons never layer the default at ${width}px`, async ({ page }, testInfo) => {
|
||||
await installFixture(page, { role: 'leader', rate: 20, testIcons: true });
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.route('**/transparent.png', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="12" fill="red"/></svg>',
|
||||
})
|
||||
);
|
||||
await page.route('**/missing.png', (route) => route.fulfill({ status: 404, body: '' }));
|
||||
await page.route('**/icons/default.jpg', async (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'image/jpeg',
|
||||
body: await referenceAsset('icons/default.jpg'),
|
||||
})
|
||||
);
|
||||
await gotoOffice(page, 'nation/personnel');
|
||||
const chief = page.locator('.chief-entry').filter({ hasText: '순욱' }).locator('.general-icon');
|
||||
await expect(chief).toHaveCSS('background-image', 'none');
|
||||
await expect(chief).toHaveAttribute('src', /transparent\.png$/);
|
||||
await expect(page.locator('.chief-entry').filter({ hasText: '조조' }).locator('.general-icon')).toHaveAttribute(
|
||||
'src',
|
||||
/default\.jpg$/
|
||||
);
|
||||
await page.getByRole('button', { name: '주부 변경하기', exact: true }).click();
|
||||
const picker = page.getByTestId('personnel-selection-dialog');
|
||||
const custom = picker
|
||||
.locator('.personnel-picker-card')
|
||||
.filter({ hasText: '순욱' })
|
||||
.locator('.personnel-picker-portrait');
|
||||
const missing = picker
|
||||
.locator('.personnel-picker-card')
|
||||
.filter({ hasText: '하후돈' })
|
||||
.locator('.personnel-picker-portrait');
|
||||
await expect(custom).toHaveCSS('background-image', 'none');
|
||||
await expect(custom).toHaveAttribute('src', /transparent\.png$/);
|
||||
await expect(missing).toHaveAttribute('src', /default\.jpg$/);
|
||||
await expect.poll(() => custom.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBe(64);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const metrics = await page.locator('.general-icon, .personnel-picker-portrait').evaluateAll((elements) =>
|
||||
elements.map((el) => ({
|
||||
src: (el as HTMLImageElement).currentSrc,
|
||||
rect: el.getBoundingClientRect().toJSON(),
|
||||
background: getComputedStyle(el).backgroundImage,
|
||||
objectFit: getComputedStyle(el).objectFit,
|
||||
naturalWidth: (el as HTMLImageElement).naturalWidth,
|
||||
naturalHeight: (el as HTMLImageElement).naturalHeight,
|
||||
}))
|
||||
);
|
||||
expect(metrics.every((entry) => entry.background === 'none')).toBe(true);
|
||||
await writeFile(testInfo.outputPath('icon-geometry.json'), JSON.stringify(metrics, null, 2));
|
||||
await writeFile(testInfo.outputPath('icon-dom.html'), await page.content());
|
||||
await testInfo.attach('icon-geometry', {
|
||||
body: JSON.stringify(metrics, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('personnel-icons.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
await testInfo.attach('icon-dom', { body: await page.content(), contentType: 'text/html' });
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`재정 격자 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await installFixture(page, { role: 'head', rate: 20 });
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoOffice(page, 'nation/finance');
|
||||
await verifyVerticalAlignment(page, testInfo, ['.diplomacy-row > div', '.budget-row > span', '.blue-heading']);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1000, 390]) {
|
||||
test(`city office requests show defaults and both decisions at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state: FixtureState = {
|
||||
role: 'head',
|
||||
rate: 20,
|
||||
officeRequests: {
|
||||
canManage: true,
|
||||
generalId: 2,
|
||||
nationId: 1,
|
||||
serverId: 'office-season',
|
||||
city: { id: 1, name: '허창' },
|
||||
options: [],
|
||||
running: true,
|
||||
tickSeconds: 600,
|
||||
requests: ['approve', 'reject'].map((defaultDecision, index) => ({
|
||||
id: `3:${index}`,
|
||||
generalId: 3 + index,
|
||||
generalName: index ? '장료' : '허저',
|
||||
cityId: 1,
|
||||
cityName: '허창',
|
||||
officerLevel: index ? 3 : 4,
|
||||
status: 'pending',
|
||||
defaultDecision,
|
||||
defaultReason: index ? 'secret' : 'vacant',
|
||||
dueYear: 190,
|
||||
dueMonth: 2,
|
||||
remainingSeconds: 540,
|
||||
invalidReason: null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.goto('nation/personnel');
|
||||
const panel = page.getByRole('region', { name: '도시 관직 자원' });
|
||||
await expect(panel.getByText('미처리 시 자동 승인', { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText('미처리 시 자동 거부', { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole('button', { name: '승인', exact: true })).toHaveCount(2);
|
||||
await expect(panel.getByRole('button', { name: '거부', exact: true })).toHaveCount(2);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await panel.evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
font: getComputedStyle(element).fontSize,
|
||||
}));
|
||||
expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth);
|
||||
await page.screenshot({ path: testInfo.outputPath(`office-requests-${width}.png`), fullPage: true });
|
||||
await writeFile(testInfo.outputPath(`office-requests-${width}.json`), JSON.stringify(geometry));
|
||||
await writeFile(
|
||||
testInfo.outputPath(`office-requests-${width}.html`),
|
||||
await panel.evaluate((element) => element.outerHTML)
|
||||
);
|
||||
const approval = panel.getByRole('button', { name: '승인', exact: true }).first();
|
||||
await approval.hover();
|
||||
await approval.focus();
|
||||
await expect(approval).toBeFocused();
|
||||
await writeFile(
|
||||
testInfo.outputPath(`office-requests-button-${width}.json`),
|
||||
JSON.stringify(
|
||||
await approval.evaluate((element) => ({
|
||||
color: getComputedStyle(element).color,
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
lineHeight: getComputedStyle(element).lineHeight,
|
||||
pointer: getComputedStyle(element).cursor,
|
||||
focused: document.activeElement === element,
|
||||
}))
|
||||
)
|
||||
);
|
||||
await panel.getByRole('button', { name: '승인', exact: true }).nth(1).click();
|
||||
await expect(page.getByTestId('game-notice-dialog')).toContainText('승인 시 기밀 열람 가능');
|
||||
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '승인', exact: true }).click();
|
||||
await expect.poll(() => state.officeMutation?.action).toBe('approve');
|
||||
expect(state.officeMutation).toMatchObject({ targetGeneralId: 4, officeRequestId: '3:1' });
|
||||
await panel.getByRole('button', { name: '거부', exact: true }).first().click();
|
||||
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '거부', exact: true }).click();
|
||||
await expect.poll(() => state.officeMutation?.action).toBe('reject');
|
||||
});
|
||||
test(`city office volunteer confirms the default at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state: FixtureState = {
|
||||
role: 'member',
|
||||
rate: 20,
|
||||
officeRequests: {
|
||||
canManage: false,
|
||||
generalId: 6,
|
||||
nationId: 1,
|
||||
serverId: 'office-season',
|
||||
city: { id: 1, name: '허창' },
|
||||
requests: [],
|
||||
running: true,
|
||||
tickSeconds: 600,
|
||||
options: [
|
||||
{ level: 4, allowed: true, reason: null, defaultDecision: 'approve', defaultReason: 'vacant' },
|
||||
{
|
||||
level: 3,
|
||||
allowed: false,
|
||||
reason: '지력 부족',
|
||||
defaultDecision: 'approve',
|
||||
defaultReason: 'vacant',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.goto('nation/personnel');
|
||||
const panel = page.getByRole('region', { name: '도시 관직 자원' });
|
||||
await expect(panel.getByRole('button', { name: '자원', exact: true }).nth(1)).toBeDisabled();
|
||||
await panel.getByRole('button', { name: '자원', exact: true }).first().click();
|
||||
await expect(page.getByTestId('game-notice-dialog')).toContainText('미처리 시 자동 승인');
|
||||
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '자원', exact: true }).click();
|
||||
await expect.poll(() => state.officeMutation?.action).toBe('request');
|
||||
expect(state.officeMutation).toEqual({ action: 'request', officerLevel: 4 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { devices, expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
@@ -454,3 +455,12 @@ test('a user below secret read permission receives a recoverable page error', as
|
||||
await expect(page.locator('#container')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`NPC 정책 제목 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await installFixture(page, { permissionLevel: 4, mutations: [] });
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await verifyVerticalAlignment(page, testInfo, ['.section_bar', '.sub_bar']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
@@ -378,3 +379,13 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
||||
expect(detailMetrics.hallColumns.split(' ')).toHaveLength(3);
|
||||
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom);
|
||||
});
|
||||
|
||||
for (const width of [500, 1000]) {
|
||||
test(`지난 플레이 전투 수치 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
|
||||
await installArchive(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto('past-plays');
|
||||
await page.locator('.detail-toggle').click();
|
||||
await verifyVerticalAlignment(page, testInfo, ['.hall-battle-record dt', '.hall-battle-record dd']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,16 @@ const world = {
|
||||
collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' },
|
||||
};
|
||||
const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 };
|
||||
const population = { count: 2, gold: 200, rice: 400, dex, averageGold: 100, averageRice: 200, averageDex: dex };
|
||||
const population = {
|
||||
crew: 5000,
|
||||
count: 2,
|
||||
gold: 200,
|
||||
rice: 400,
|
||||
dex,
|
||||
averageGold: 100,
|
||||
averageRice: 200,
|
||||
averageDex: dex,
|
||||
};
|
||||
const general = {
|
||||
id: 1,
|
||||
name: '감사장수',
|
||||
@@ -76,7 +85,9 @@ const install = async (
|
||||
page: Page,
|
||||
denied = false,
|
||||
baseline: boolean | 'document' | 'created' | 'removed' = false,
|
||||
executionStatus?: 'PREPARING' | 'BLOCKED'
|
||||
executionStatus?: 'PREPARING' | 'BLOCKED',
|
||||
dashboard = false,
|
||||
historyGap = false
|
||||
) => {
|
||||
const requests: { operation: string; input: Record<string, unknown> }[] = [];
|
||||
await page.addInitScript((profile) => {
|
||||
@@ -136,7 +147,9 @@ const install = async (
|
||||
case 'playAudit.decisionHistory':
|
||||
return result({
|
||||
...world,
|
||||
month: input.month ?? { year: 190, month: 7 },
|
||||
month: input.month ?? { year: 190, month: 6 },
|
||||
currentMonth: { year: 190, month: 7 },
|
||||
selection: input.month ? 'MONTH' : 'LATEST',
|
||||
coverage: 'PROCEDURES_ONLY',
|
||||
items: [
|
||||
{
|
||||
@@ -156,7 +169,59 @@ const install = async (
|
||||
{
|
||||
ordinal: input.cursor === undefined ? 0 : 1,
|
||||
steps: [
|
||||
...(input.cursor === undefined ? [{ phase: 'general', generalId: 1, nationId: 2, cityId: 3, npcState: 2, year: 190, month: 6, tick: 100, sequence: 0, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5]}} }] : []),
|
||||
...(input.cursor === undefined
|
||||
? [
|
||||
{
|
||||
phase: 'general',
|
||||
generalId: 1,
|
||||
nationId: 2,
|
||||
cityId: 3,
|
||||
npcState: 2,
|
||||
year: 190,
|
||||
month: 6,
|
||||
tick: 100,
|
||||
sequence: 0,
|
||||
kind: 'DECISION_START',
|
||||
reservedAction: '휴식',
|
||||
effectivePolicy: {
|
||||
schemaVersion: 1,
|
||||
general: {
|
||||
priority: ['징병'],
|
||||
flags: { 징병: true, 출병: false },
|
||||
},
|
||||
nation: {
|
||||
priority: ['천도'],
|
||||
flags: { 천도: true },
|
||||
values: {
|
||||
reqNationGold: 4321,
|
||||
reqNationRice: 100,
|
||||
reqHumanWarUrgentGold: 100,
|
||||
reqHumanWarUrgentRice: 100,
|
||||
reqHumanWarRecommandGold: 100,
|
||||
reqHumanWarRecommandRice: 100,
|
||||
reqHumanDevelGold: 100,
|
||||
reqHumanDevelRice: 100,
|
||||
reqNpcWarGold: 100,
|
||||
reqNpcWarRice: 100,
|
||||
reqNpcDevelGold: 100,
|
||||
reqNpcDevelRice: 100,
|
||||
minimumResourceActionAmount: 100,
|
||||
maximumResourceActionAmount: 100,
|
||||
minNpcWarLeadership: 100,
|
||||
minWarCrew: 100,
|
||||
minNpcRecruitCityPopulation: 100,
|
||||
safeRecruitCityPopulationRatio: 100,
|
||||
properWarTrainAtmos: 100,
|
||||
cureThreshold: 100,
|
||||
},
|
||||
combatForce: { '1': [2, 3] },
|
||||
supportForce: [4],
|
||||
developForce: [5],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
phase: 'general',
|
||||
generalId: 1,
|
||||
@@ -431,7 +496,13 @@ const install = async (
|
||||
});
|
||||
}
|
||||
case 'playAudit.coverage':
|
||||
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null });
|
||||
return result({
|
||||
...world,
|
||||
historyGap: historyGap ? { firstYear: 190, firstMonth: 4 } : null,
|
||||
status: 'COLLECTED',
|
||||
samples: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
case 'playAudit.nations':
|
||||
return result({
|
||||
...world,
|
||||
@@ -442,6 +513,13 @@ const install = async (
|
||||
case 'playAudit.nationSeries':
|
||||
return result({
|
||||
...world,
|
||||
currentSettlement: {
|
||||
year: 190,
|
||||
month: 7,
|
||||
complete: true,
|
||||
gold: null,
|
||||
rice: { income: 4321, paid: 1234 },
|
||||
},
|
||||
nextCursor: null,
|
||||
items: [
|
||||
{
|
||||
@@ -471,7 +549,33 @@ const install = async (
|
||||
settlementsComplete: true,
|
||||
})),
|
||||
},
|
||||
],
|
||||
].flatMap((point) =>
|
||||
dashboard
|
||||
? Array.from({ length: 6 }, (_, index) => ({
|
||||
...point,
|
||||
month: index + 1,
|
||||
periodMonths: 1,
|
||||
from: { year: 190, month: index + 1 },
|
||||
to: { year: 190, month: index + 1 },
|
||||
stockAsOf: { year: 190, month: index + 1 },
|
||||
stock:
|
||||
index === 2
|
||||
? null
|
||||
: {
|
||||
...point.stock,
|
||||
gold: 600 + index * 150,
|
||||
rice: 1200 - index * 90,
|
||||
populations: {
|
||||
human: { ...population, crew: 5000 + index * 1000 },
|
||||
npc: population,
|
||||
troopNpc: population,
|
||||
},
|
||||
},
|
||||
flows: { ...point.flows, incomeGold: index === 0 ? 2100 : 0 },
|
||||
complete: index !== 2,
|
||||
}))
|
||||
: [point]
|
||||
),
|
||||
});
|
||||
case 'playAudit.nationSnapshot':
|
||||
return result({
|
||||
@@ -503,7 +607,11 @@ const install = async (
|
||||
...world,
|
||||
collected: !input.at || (input.at as { month: number }).month !== 7,
|
||||
sample: input.at ?? null,
|
||||
nextCursor: input.cursor ? null : 1,
|
||||
nextCursor: input.cursor
|
||||
? null
|
||||
: input.sort && input.sort !== 'id'
|
||||
? { id: 1, value: 1200 }
|
||||
: 1,
|
||||
items:
|
||||
input.at && (input.at as { month: number }).month === 7
|
||||
? []
|
||||
@@ -524,6 +632,27 @@ const install = async (
|
||||
nation: { id: 2, name: '촉' },
|
||||
city: { id: 3, name: '성도' },
|
||||
});
|
||||
case 'playAudit.cityMap':
|
||||
return result({
|
||||
...world,
|
||||
collected: true,
|
||||
sample: input.at ?? null,
|
||||
nextCursor: null,
|
||||
unmappedCityIds: [],
|
||||
layout: {
|
||||
mapName: 'che',
|
||||
cityList: [{ id: 3, name: '성도', level: 4, region: 1, x: 200, y: 180, path: [] }],
|
||||
regionMap: { 1: '익주' },
|
||||
levelMap: { 4: '이' },
|
||||
},
|
||||
map: {
|
||||
year: 190,
|
||||
month: (input.at as { month: number } | undefined)?.month ?? 7,
|
||||
startYear: 190,
|
||||
cityList: [[3, 4, 0, 2, 1, 1]],
|
||||
nationList: [[2, '촉', '#ff0000', 0]],
|
||||
},
|
||||
});
|
||||
case 'playAudit.cityDetail':
|
||||
return result({
|
||||
...world,
|
||||
@@ -952,7 +1081,9 @@ test('policy filter drafts do not read until applied, including default dates',
|
||||
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
|
||||
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
|
||||
await page.getByRole('link', { name: '국방 설정', exact: true }).click();
|
||||
await expect.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe('DEFENCE');
|
||||
await expect
|
||||
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area)
|
||||
.toBe('DEFENCE');
|
||||
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
|
||||
await page.getByLabel('시작 월', { exact: true }).fill('3');
|
||||
await page.getByRole('button', { name: '조회', exact: true }).focus();
|
||||
@@ -1337,7 +1468,10 @@ for (const width of [1280, 390]) {
|
||||
await menu.getByRole('link', { name: '국방 설정', exact: true }).click();
|
||||
await expect(page).toHaveURL(/policyArea=DEFENCE/);
|
||||
await page.goBack();
|
||||
await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
);
|
||||
await page.goBack();
|
||||
await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
await menu.getByRole('link', { name: '도시', exact: true }).hover();
|
||||
@@ -1357,3 +1491,161 @@ for (const width of [1280, 390]) {
|
||||
await page.screenshot({ path: `/tmp/play-audit-menu/${width}.png`, fullPage: true });
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`audit dashboard charts, nation navigation and inspector at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 960 });
|
||||
const requests = await install(page, false, false, undefined, true);
|
||||
await page.goto(gamePath('/play-audit'));
|
||||
await page.getByRole('button', { name: '촉 #2', exact: true }).click();
|
||||
await expect(page).toHaveURL(/nation=2/);
|
||||
await expect(page.getByRole('region', { name: '당월 정산', exact: true })).toContainText('4,321');
|
||||
const charts = page.locator('.audit-chart canvas');
|
||||
await expect(charts).toHaveCount(4);
|
||||
const sizes = await charts.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
width: node.getBoundingClientRect().width,
|
||||
height: node.getBoundingClientRect().height,
|
||||
}))
|
||||
);
|
||||
for (const size of sizes) {
|
||||
expect(size.width).toBeGreaterThan(width === 390 ? 260 : 600);
|
||||
expect(size.height).toBeGreaterThanOrEqual(260);
|
||||
}
|
||||
await page.getByLabel('지표', { exact: true }).selectOption('crew');
|
||||
await expect(page.getByRole('cell', { name: '15,000', exact: true })).toBeVisible();
|
||||
const query = requests.findLast((request) => request.operation === 'playAudit.nationSeries')?.input;
|
||||
expect(query).toMatchObject({ nationId: 2, resolution: 'month' });
|
||||
await capture(page, `dashboard-${width}`);
|
||||
await page.getByRole('button', { name: '최근 6개월', exact: true }).click();
|
||||
await expect(page).toHaveURL(/fromMonth=2/);
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: '플레이 감사 메뉴' })
|
||||
.getByRole('link', { name: '장수', exact: true })
|
||||
.click();
|
||||
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
|
||||
const detail = page.getByRole('complementary', { name: '선택한 대상 상세' });
|
||||
await expect(detail).toContainText('병력 5,000');
|
||||
await expect(detail).toBeFocused();
|
||||
if (width >= 1200) {
|
||||
const left = await page.getByRole('region', { name: '감사 분석', exact: true }).boundingBox();
|
||||
const right = await detail.boundingBox();
|
||||
expect(right!.x).toBeGreaterThanOrEqual(left!.x + left!.width);
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
await capture(page, `inspector-${width}`);
|
||||
await page.reload();
|
||||
await expect(detail).toContainText('병력 5,000');
|
||||
await expect(detail).toBeFocused();
|
||||
if (width < 1200) await expect(detail).toBeInViewport();
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1280, 390]) {
|
||||
test(`audit historical map and ranked generals at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.route('https://sam-image.hided.net/**', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="700" height="500"><rect width="700" height="500" fill="#203c2d"/></svg>',
|
||||
})
|
||||
);
|
||||
const requests = await install(page);
|
||||
await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6'));
|
||||
expect(requests.some((request) => request.operation === 'playAudit.cityMap')).toBe(false);
|
||||
await page.getByRole('link', { name: '도시 지도', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '성도', exact: true }).click();
|
||||
await expect(page).toHaveURL(/cityRecord=3/);
|
||||
await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible();
|
||||
await capture(page, `audit-map-${width}`);
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click();
|
||||
await expect(page).toHaveURL(/city=3/);
|
||||
await page.getByLabel('정렬 기준', { exact: true }).selectOption('gold');
|
||||
await page.getByLabel('정렬 방향', { exact: true }).selectOption('desc');
|
||||
await page.getByRole('button', { name: '조회', exact: true }).click();
|
||||
await expect(page).toHaveURL(/sort=gold/);
|
||||
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
|
||||
await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible();
|
||||
expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({
|
||||
sort: 'gold',
|
||||
order: 'desc',
|
||||
cityId: 3,
|
||||
at: { year: 190, month: 6, kind: 'MONTH_END' },
|
||||
cursor: { id: 1, value: 1200 },
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('정렬 기준', { exact: true })).toHaveValue('gold');
|
||||
await capture(page, `audit-ranked-${width}`);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
test(`audit history gap stays visible while browsing at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await install(page, false, false, undefined, false, true);
|
||||
await page.goto(gamePath('/play-audit'));
|
||||
const notice = page.getByRole('status').filter({ hasText: '감사 이력에 누락된 구간' });
|
||||
await expect(notice).toContainText('190년 4월');
|
||||
await page.getByRole('link', { name: '장수', exact: true }).click();
|
||||
await expect(notice).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await notice.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
fontSize: style.fontSize,
|
||||
viewport: innerWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.x).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.x + geometry.width).toBeLessThanOrEqual(width);
|
||||
await writeFile(testInfo.outputPath('audit-gap-geometry.json'), JSON.stringify(geometry));
|
||||
await page.screenshot({ path: testInfo.outputPath('audit-gap.png'), fullPage: true });
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
test(`NPC decision month navigation shows latest recorded month at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const requests = await install(page);
|
||||
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
|
||||
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
|
||||
await expect(page.getByText('가장 최근 결정이 수집된 월입니다.', { exact: false })).toContainText('190년 7월');
|
||||
const panel = page.getByRole('region', { name: 'NPC 결정 기록', exact: true });
|
||||
await expect(panel).toContainText('190년 6월');
|
||||
await panel.getByRole('button', { name: '다음 월', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input)
|
||||
.toMatchObject({ month: { year: 190, month: 7 } });
|
||||
await expect(panel.getByRole('button', { name: '다음 월', exact: true })).toBeDisabled();
|
||||
await panel.getByRole('button', { name: '이전 월', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input)
|
||||
.toMatchObject({ month: { year: 190, month: 6 } });
|
||||
await panel.getByRole('button', { name: '최근 결정 조회', exact: true }).click();
|
||||
await expect(page.getByText('가장 최근 결정이 수집된 월입니다.', { exact: false })).toBeVisible();
|
||||
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).not.toHaveProperty(
|
||||
'month'
|
||||
);
|
||||
await capture(page, `npc-decision-latest-${width}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('direct decision URL keeps the history panel open when changing month', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.goto(gamePath(`/play-audit?tab=generals&general=1&decision=${decision.id}`));
|
||||
const panel = page.getByRole('region', { name: 'NPC 결정 기록', exact: true });
|
||||
await expect(panel.getByRole('list', { name: '판단 절차' })).toBeVisible();
|
||||
await panel.getByRole('button', { name: '이전 월', exact: true }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByRole('region', { name: '선택 결정 상세' })).toHaveCount(0);
|
||||
await panel.getByRole('button', { name: '최근 결정 조회', exact: true }).click();
|
||||
await expect(panel).toContainText('가장 최근 결정이 수집된 월입니다.');
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user