Merge branch 'main' into feature/ingame-message-parity

This commit is contained in:
2026-07-26 05:38:17 +00:00
42 changed files with 2094 additions and 336 deletions
@@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes
import { processBattleSimJob } from './processor.js';
export class InMemoryBattleSimTransport {
private readonly results = new Map<string, BattleSimResultPayload>();
private readonly results = new Map<string, { requesterUserId: string; payload: BattleSimResultPayload }>();
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const result = processBattleSimJob(payload);
this.results.set(jobId, result);
this.results.set(jobId, { requesterUserId, payload: result });
return { status: 'completed', jobId, payload: result };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.results.get(jobId) ?? null;
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
const result = this.results.get(jobId);
if (!result || result.requesterUserId !== requesterUserId) {
return null;
}
return result.payload;
}
}
+22 -17
View File
@@ -44,16 +44,16 @@ export class RedisBattleSimTransport {
this.resultTtlSeconds = options.resultTtlSeconds;
}
private buildResultKey(jobId: string): string {
return `${this.keys.resultKeyPrefix}${jobId}`;
private buildResultKey(jobId: string, requesterUserId: string): string {
return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
}
private buildNotifyKey(jobId: string): string {
return `${this.keys.notifyKeyPrefix}${jobId}`;
private buildNotifyKey(jobId: string, requesterUserId: string): string {
return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
}
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId));
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId));
if (!raw) {
return null;
}
@@ -64,44 +64,49 @@ export class RedisBattleSimTransport {
}
}
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId);
private async waitForResult(
jobId: string,
requesterUserId: string,
timeoutMs: number
): Promise<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId, requesterUserId);
if (existing) {
return existing;
}
const notifyKey = this.buildNotifyKey(jobId);
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
const timeoutSec = toTimeoutSeconds(timeoutMs);
const signal = await this.client.blPop(notifyKey, timeoutSec);
if (!parseBlPopValue(signal)) {
return null;
}
return this.readResult(jobId);
return this.readResult(jobId, requesterUserId);
}
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const job = {
jobId,
requesterUserId,
requestedAt: new Date().toISOString(),
payload,
};
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
const result = await this.waitForResult(jobId, this.requestTimeoutMs);
const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs);
if (result) {
return { status: 'completed', jobId, payload: result };
}
return { status: 'queued', jobId };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId);
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId, requesterUserId);
}
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId);
const notifyKey = this.buildNotifyKey(jobId);
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId, requesterUserId);
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
await this.client.set(resultKey, JSON.stringify(payload), {
EX: this.resultTtlSeconds,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
export interface BattleSimTransport {
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
}
+1
View File
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
export interface BattleSimJob {
jobId: string;
requesterUserId: string;
requestedAt: string;
payload: BattleSimJobPayload;
}
+42 -24
View File
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
return result.element ?? null;
};
export const runBattleSimWorker = async (): Promise<void> => {
export interface BattleSimWorkerOptions {
signal?: AbortSignal;
}
export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise<void> => {
const config = resolveGameApiConfigFromEnv();
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
resultTtlSeconds: config.battleSimResultTtlSeconds,
});
const handleExit = async () => {
await redis.disconnect();
let stopped = options.signal?.aborted ?? false;
const handleExit = () => {
stopped = true;
};
const handleAbort = () => {
stopped = true;
};
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
options.signal?.addEventListener('abort', handleAbort, { once: true });
while (true) {
const item = await redis.client.blPop(keys.queueKey, 0);
const raw = parseBlPopValue(item);
if (!raw) {
continue;
}
try {
while (!stopped) {
// A finite block lets SIGTERM and test AbortSignal stop the worker without
// leaving a Redis operation or a detached lifecycle process behind.
const item = await redis.client.blPop(keys.queueKey, 1);
const raw = parseBlPopValue(item);
if (!raw) {
continue;
}
let job: BattleSimJob | null = null;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
continue;
}
let job: BattleSimJob | null = null;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
continue;
}
try {
const result = processBattleSimJob(job.payload);
await transport.pushResult(job.jobId, result);
} catch (error) {
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
await transport.pushResult(job.jobId, {
result: false,
reason,
});
try {
const result = processBattleSimJob(job.payload);
await transport.pushResult(job.jobId, job.requesterUserId, result);
} catch (error) {
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
await transport.pushResult(job.jobId, job.requesterUserId, {
result: false,
reason,
});
}
}
} finally {
process.off('SIGINT', handleExit);
process.off('SIGTERM', handleExit);
options.signal?.removeEventListener('abort', handleAbort);
await redis.disconnect();
}
};
+13 -5
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { getDexLevel } from '@sammo-ts/logic';
import { authedProcedure, procedure, router } from '../../trpc.js';
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
import {
@@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => {
return value;
};
const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => {
const userId = auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
}
return userId;
};
const resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
const expLevel = meta.explevel ?? meta.expLevel;
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
@@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
};
export const battleRouter = router({
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
@@ -58,10 +66,10 @@ export const battleRouter = router({
}
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
return ctx.battleSim.simulate(payload);
return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth));
}),
getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId);
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
if (!result) {
return { status: 'queued', jobId: input.jobId };
}
+20 -14
View File
@@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar
const t = initTRPC.context<GameApiContext>().create();
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
});
}
return next({
ctx: {
...ctx,
auth: ctx.auth,
},
});
});
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
@@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
if (!ctx.auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
});
}
return next({
ctx: {
...ctx,
auth: ctx.auth,
},
});
});
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
+2
View File
@@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
};
};
+230 -21
View File
@@ -19,19 +19,30 @@ const profile: GameProfile = {
class QueuedBattleSimTransport implements BattleSimTransport {
public simulateCalls = 0;
public lastPayload: BattleSimJobPayload | null = null;
public lastRequesterUserId: string | null = null;
private readonly owners = new Map<string, string>();
private readonly results = new Map<string, BattleSimResultPayload>();
async simulate(payload: BattleSimJobPayload) {
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
this.simulateCalls += 1;
this.lastPayload = payload;
return { status: 'queued', jobId: 'job-1' } as const;
this.lastRequesterUserId = requesterUserId;
const jobId = `job-${this.simulateCalls}`;
this.owners.set(jobId, requesterUserId);
return { status: 'queued', jobId } as const;
}
async getSimulationResult(jobId: string) {
async getSimulationResult(jobId: string, requesterUserId: string) {
if (this.owners.get(jobId) !== requesterUserId) {
return null;
}
return this.results.get(jobId) ?? null;
}
pushResult(jobId: string, payload: BattleSimResultPayload) {
pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) {
if (this.owners.get(jobId) !== requesterUserId) {
throw new Error('requester mismatch');
}
this.results.set(jobId, payload);
}
}
@@ -194,8 +205,13 @@ const buildBattleRequest = () => ({
},
});
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
const db = {
const buildContext = (options: {
state: WorldStateRow;
battleSim: BattleSimTransport;
userId?: string | null;
db?: Partial<DatabaseClient>;
}): GameApiContext => {
const db = options.db ?? {
worldState: {
findFirst: async () => options.state,
},
@@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans
},
profile.name
);
const auth: GameSessionTokenPayload = {
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
sessionId: 'session-1',
user: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
const auth: GameSessionTokenPayload | null =
options.userId === null
? null
: {
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
sessionId: 'session-1',
user: {
id: options.userId ?? 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
@@ -255,14 +274,204 @@ describe('battle router orchestration', () => {
const response = await caller.battle.simulate(buildBattleRequest());
expect(response.status).toBe('queued');
expect(battleSim.simulateCalls).toBe(1);
expect(battleSim.lastRequesterUserId).toBe('user-1');
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
expect(queued.status).toBe('queued');
battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 });
battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 });
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
expect(completed.status).toBe('completed');
expect(completed.payload?.result).toBe(true);
});
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
let transactionCalls = 0;
const db = {
worldState: { findFirst: async () => state },
$transaction: async () => {
transactionCalls += 1;
throw new Error('simulation must not create an input event transaction');
},
} as unknown as DatabaseClient;
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const noGeneralUser = appRouter.createCaller(
buildContext({ state, battleSim, userId: 'user-without-general', db })
);
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
status: 'queued',
});
expect(transactionCalls).toBe(0);
expect(battleSim.lastRequesterUserId).toBe('user-without-general');
});
it('does not expose queued results across authenticated users', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' }));
const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' }));
const response = await owner.battle.simulate(buildBattleRequest());
battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 });
await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({
status: 'completed',
payload: { avgWar: 7 },
});
await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({
status: 'queued',
jobId: response.jobId,
});
});
});
describe('battle simulator general import permissions', () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const buildGeneral = (overrides: Record<string, unknown>) => ({
id: 1,
userId: 'same-nation-user',
name: '관전자',
npcState: 0,
nationId: 1,
leadership: 70,
strength: 71,
intel: 72,
officerLevel: 1,
injury: 0,
rice: 9000,
crew: 5000,
crewTypeId: 100,
atmos: 100,
train: 100,
experience: 400,
horseCode: null,
weaponCode: null,
bookCode: null,
itemCode: null,
personalCode: null,
special2Code: null,
meta: {},
...overrides,
});
const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 });
const ally = buildGeneral({
id: 2,
userId: 'ally-user',
name: '아군 장수',
nationId: 1,
officerLevel: 4,
rice: 4321,
crew: 3210,
train: 97,
atmos: 96,
horseCode: 'che_적토마',
weaponCode: 'che_의천검',
bookCode: 'che_손자병법',
itemCode: 'che_옥새',
meta: {
dex1: 10000,
rank_warnum: 33,
rank_killnum: 22,
rank_killcrew: 1111,
},
});
const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 });
const generals = [actor, ally, foreignActor];
const db = {
worldState: { findFirst: async () => state },
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
generals.find((general) => general.userId === where.userId) ?? null,
findUnique: async ({ where }: { where: { id: number } }) =>
generals.find((general) => general.id === where.id) ?? null,
},
} as unknown as DatabaseClient;
it('returns full ally details to the same nation but redacts them for another nation', async () => {
const battleSim = new QueuedBattleSimTransport();
const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db }));
const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db }));
const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id });
expect(visible.general).toMatchObject({
name: '아군 장수',
officer_level: 4,
horse: 'che_적토마',
crew: 3210,
rice: 4321,
train: 97,
atmos: 96,
warnum: 33,
killnum: 22,
killcrew: 1111,
});
const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id });
expect(redacted.general).toMatchObject({
name: '아군 장수',
officer_level: 1,
horse: null,
weapon: null,
book: null,
item: null,
crew: 0,
rice: 10000,
dex1: 0,
warnum: 0,
killnum: 0,
killcrew: 0,
});
});
it('requires a game general only for server-side general import', async () => {
const caller = appRouter.createCaller(
buildContext({
state,
battleSim: new QueuedBattleSimTransport(),
userId: 'user-without-general',
db,
})
);
await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js';
class FakeRedisClient {
readonly values = new Map<string, string>();
readonly lists = new Map<string, string[]>();
async rPush(key: string, value: string): Promise<number> {
const list = this.lists.get(key) ?? [];
list.push(value);
this.lists.set(key, list);
return list.length;
}
async blPop(): Promise<null> {
return null;
}
async set(key: string, value: string): Promise<'OK'> {
this.values.set(key, value);
return 'OK';
}
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async expire(): Promise<number> {
return 1;
}
}
describe('RedisBattleSimTransport requester isolation', () => {
it('records the requester on queued jobs and scopes completed results to that user', async () => {
const client = new FakeRedisClient();
const keys = buildBattleSimQueueKeys('che:test');
const transport = new RedisBattleSimTransport(client, {
keys,
requestTimeoutMs: 1,
resultTtlSeconds: 60,
});
const response = await transport.simulate({} as BattleSimJobPayload, 'user/one');
expect(response.status).toBe('queued');
const queuedRaw = client.lists.get(keys.queueKey)?.[0];
expect(queuedRaw).toBeTruthy();
expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({
jobId: response.jobId,
requesterUserId: 'user/one',
});
await transport.pushResult(response.jobId, 'user/one', {
result: true,
reason: 'success',
avgWar: 3,
});
await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({
result: true,
avgWar: 3,
});
await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull();
expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true);
});
});
@@ -0,0 +1,94 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildBattleSimEnvironment } from '../src/battleSim/environment.js';
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
import type { BattleSimRequestPayload } from '../src/battleSim/types.js';
import { runBattleSimWorker } from '../src/battleSim/worker.js';
import type { WorldStateRow } from '../src/context.js';
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
afterEach(() => {
vi.unstubAllEnvs();
});
liveDescribe('battle simulator worker with live Redis', () => {
it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => {
const scenario = `battle-sim-e2e-${randomUUID()}`;
const profileName = `che:${scenario}`;
const requesterUserId = 'worker-e2e-user';
vi.stubEnv('PROFILE', 'che');
vi.stubEnv('SCENARIO', scenario);
vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only');
const fixturePath = path.resolve(
process.cwd(),
'../../tools/integration-tests/fixtures/battle/basic-infantry.json'
);
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & {
startYear: number;
};
const { startYear, ...request } = fixture;
const worldState: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: request.year,
currentMonth: request.month,
tickSeconds: 600,
config: {},
meta: { scenarioMeta: { startYear } },
updatedAt: new Date(),
};
const environment = await buildBattleSimEnvironment(worldState, 'che');
const payload = {
...request,
unitSet: environment.unitSet,
config: environment.config,
time: { year: request.year, month: request.month, startYear },
};
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
await clientConnector.connect();
const keys = buildBattleSimQueueKeys(profileName);
const transport = new RedisBattleSimTransport(clientConnector.client, {
keys,
requestTimeoutMs: 15_000,
resultTtlSeconds: 60,
});
const abortController = new AbortController();
const worker = runBattleSimWorker({ signal: abortController.signal });
let jobId: string | null = null;
try {
const result = await transport.simulate(payload, requesterUserId);
jobId = result.jobId;
expect(result.status).toBe('completed');
if (result.status === 'completed') {
expect(result.payload).toMatchObject({
result: true,
reason: 'success',
avgWar: 1,
});
expect(result.payload.phase).toBeGreaterThan(0);
}
} finally {
abortController.abort();
await worker;
if (jobId) {
const encodedRequester = encodeURIComponent(requesterUserId);
await clientConnector.client.del([
keys.queueKey,
`${keys.resultKeyPrefix}${encodedRequester}:${jobId}`,
`${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`,
]);
}
await clientConnector.disconnect();
}
});
});
@@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071;
const concurrentBettingId = 990_072;
const generalId = 9_971;
const otherGeneralId = 9_972;
const nationId = 990_071;
const otherNationId = 990_072;
const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user';
const auth: GameSessionTokenPayload = {
version: 1,
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const otherAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-other-session',
user: {
...auth.user,
id: otherUserId,
username: 'other-bettor',
displayName: 'Other Bettor',
},
};
const noGeneralAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-no-general-session',
user: {
...auth.user,
id: noGeneralUserId,
username: 'no-general',
displayName: 'No General',
},
};
integration('nation betting router', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
const buildContext = (requestId: string): GameApiContext => {
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
auth,
auth: actorAuth,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId } });
await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.nation.create({
data: {
id: nationId,
name: '베팅국',
color: '#123456',
level: 2,
},
await db.nation.createMany({
data: [
{
id: nationId,
name: '베팅국',
color: '#123456',
level: 2,
},
{
id: otherNationId,
name: '다른베팅국',
color: '#654321',
level: 6,
},
],
});
await db.general.create({
data: {
id: generalId,
userId,
name: '베팅장수',
nationId,
cityId: 1,
npcState: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
await db.general.createMany({
data: [
{
id: generalId,
userId,
name: '베팅장수',
nationId,
cityId: 1,
npcState: 0,
officerLevel: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
{
id: otherGeneralId,
userId: otherUserId,
name: '다른국가수뇌',
nationId: otherNationId,
cityId: 1,
npcState: 0,
officerLevel: 12,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
],
});
const world = await db.worldState.create({
data: {
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
},
});
await db.inheritancePoint.create({
data: { userId, key: 'previous', value: 1_000 },
await db.inheritancePoint.createMany({
data: [
{ userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 },
],
});
});
afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId } });
await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.();
});
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
}),
]);
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
.toMatchObject({ _sum: { amount: 600 } });
expect(
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
).toMatchObject({ _sum: { amount: 600 } });
expect(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId, key: 'previous' } },
})
).toMatchObject({ value: 250 });
});
it('requires authentication and an owned player general for every betting operation', async () => {
await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
req: 'bettingNation',
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter
.createCaller(buildContext('nation-betting-anonymous-detail', null))
.betting.getDetail({ bettingId })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({
bettingId,
bettingType: [0],
amount: 10,
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({
req: 'bettingNation',
})
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
await expect(
appRouter
.createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth))
.betting.getDetail({ bettingId })
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
await expect(
appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({
bettingId,
bettingType: [0],
amount: 10,
})
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
});
it('allows generals across nation and office levels while isolating each session user bet', async () => {
await expect(
appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({
req: 'bettingNation',
})
).resolves.toMatchObject({
result: true,
bettingList: {
[bettingId]: { name: '천통국 예상' },
},
});
await expect(
appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({
bettingId,
bettingType: [0],
amount: 100,
})
).resolves.toEqual({ result: true });
const [firstUserDetail, otherUserDetail] = await Promise.all([
appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }),
appRouter
.createCaller(buildContext('nation-betting-other-user-detail', otherAuth))
.betting.getDetail({ bettingId }),
]);
expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]);
expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]);
expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]);
expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]);
expect(
await db.nationBet.findUniqueOrThrow({
where: {
bettingId_userId_selectionKey: {
bettingId,
userId: otherUserId,
selectionKey: '[0]',
},
},
})
).toMatchObject({
generalId: otherGeneralId,
userId: otherUserId,
amount: 100,
});
expect(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: otherUserId, key: 'previous' } },
})
).toMatchObject({ value: 400 });
expect(
await db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } },
})
).toMatchObject({ nationId: otherNationId, value: 100 });
});
});
@@ -35,6 +35,8 @@ const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10;
const DEFAULT_MAX_TECH_LEVEL = 12;
const DEFAULT_BASE_GOLD = 0;
const DEFAULT_BASE_RICE = 2000;
const DEFAULT_GENERAL_MINIMUM_GOLD = 0;
const DEFAULT_GENERAL_MINIMUM_RICE = 500;
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
const normalizeCode = (value: string | null | undefined): string | null => {
@@ -132,6 +134,8 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE),
maxResourceActionAmount: resolveNumber(
constValues,
['maxResourceActionAmount'],
@@ -0,0 +1,320 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR;
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32000,
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
},
});
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const readImage = async (relative: string): Promise<Buffer> => {
for (const root of imageRoots) {
try {
return await readFile(resolve(root, relative));
} catch {
// Main checkout and feature worktrees have different image-root parents.
}
}
throw new Error(`Reference image not found: ${relative}`);
};
const simulatorOptions = {
world: { startYear: 190, currentYear: 205, currentMonth: 8 },
config: {
maxTrainByWar: 120,
maxAtmosByWar: 120,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
},
unitSet: {
defaultCrewTypeId: 100,
crewTypes: [
{ id: 100, name: '보병', armType: 1 },
{ id: 200, name: '궁병', armType: 2 },
],
},
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }],
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
items: { horse: [], weapon: [], book: [], item: [] },
nationLevels: [
{ level: 0, name: '방랑군' },
{ level: 1, name: '소국' },
],
cityLevels: [
{ level: 1, name: '소도시' },
{ level: 5, name: '대도시' },
],
dexLevels: [
{ level: 0, label: 'F', value: 0 },
{ level: 1, label: 'E', value: 1000 },
],
};
const generalMe = {
general: {
id: 7,
name: '유비',
npcState: 0,
nationId: 1,
cityId: 1,
troopId: 0,
picture: '22.jpg',
imageServer: 0,
officerLevel: 12,
stats: { leadership: 85, strength: 72, intelligence: 78 },
gold: 1000,
rice: 8765,
crew: 4321,
train: 99,
atmos: 98,
injury: 0,
experience: 900,
dedication: 100,
items: { horse: null, weapon: null, book: null, item: null },
},
city: { id: 1, level: 1, defence: 2222, wall: 3333 },
nation: { id: 1, level: 1, tech: 4500, typeCode: 'che_중립', capitalCityId: 1 },
settings: {},
penalties: {},
};
const importedGeneral = {
general: {
no: 7,
name: '유비',
officer_level: 12,
explevel: 30,
leadership: 85,
strength: 72,
intel: 78,
horse: null,
weapon: null,
book: null,
item: null,
injury: 0,
rice: 8765,
personal: 'che_대담',
special2: 'che_필살',
crew: 4321,
crewtype: 100,
atmos: 98,
train: 99,
dex1: 1000,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
defence_train: 90,
warnum: 12,
killnum: 7,
killcrew: 3456,
},
};
const simulationResult = {
result: true,
reason: 'success',
datetime: '205-08',
avgWar: 5,
phase: 13,
killed: 1234,
maxKilled: 1400,
minKilled: 1100,
dead: 432,
maxDead: 500,
minDead: 400,
attackerRice: 321,
defenderRice: 654,
attackerSkills: { 필살: 2 },
defendersSkills: [{ 회피: 1 }],
lastWarLog: {
generalHistoryLog: '',
generalActionLog: '',
generalBattleResultLog: '<span>유비가 모의전에서 승리했습니다.</span>',
generalBattleDetailLog: '<span>필살 발동, 피해 1,234</span>',
nationalHistoryLog: '',
globalHistoryLog: '',
globalActionLog: '',
},
};
type Fixture = {
hasGeneral: boolean;
failNextSimulation?: boolean;
queueFirst?: boolean;
pollingCount: number;
requests: string[];
};
const installImages = async (page: Page) => {
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
await page.route(`**/image/game/${filename}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: await readImage(`game/${filename}`),
});
});
}
};
const installApi = async (page: Page, fixture: Fixture) => {
await installImages(page);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright');
window.localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
fixture.requests.push(operation);
if (operation === 'lobby.info') {
return response({
year: 205,
month: 8,
myGeneral: fixture.hasGeneral ? { name: '유비', picture: '22.jpg' } : null,
});
}
if (operation === 'battle.getSimulatorContext') return response(simulatorOptions);
if (operation === 'general.me') return response(fixture.hasGeneral ? generalMe : null);
if (operation === 'battle.getGeneralList') {
return response({
myNationId: 1,
myGeneralId: 7,
nations: [{ id: 1, name: '촉', color: '#8fbc8f' }],
generalsByNation: { 1: [{ id: 7, name: '유비', npcState: 0 }] },
});
}
if (operation === 'battle.getGeneralDetail') return response(importedGeneral);
if (operation === 'battle.simulate') {
if (fixture.failNextSimulation) {
fixture.failNextSimulation = false;
return errorResponse(operation, '시뮬레이터 입력 오류');
}
if (fixture.queueFirst) {
return response({ status: 'queued', jobId: 'job-playwright' });
}
return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult });
}
if (operation === 'battle.getSimulation') {
fixture.pollingCount += 1;
if (fixture.pollingCount === 1) {
return response({ status: 'queued', jobId: 'job-playwright' });
}
return response({
status: 'completed',
jobId: 'job-playwright',
payload: simulationResult,
});
}
return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
const gotoSimulator = async (page: Page) => {
await page.goto('battle-simulator');
await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible();
await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible();
await expect(page.getByText('출병자 설정')).toBeVisible();
};
test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => {
const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] };
await installApi(page, fixture);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page);
const notice = page.getByLabel('시뮬레이터 데이터 안내');
const noticeRect = await notice.boundingBox();
expect(noticeRect?.width).toBeGreaterThan(900);
expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex');
await page.getByRole('button', { name: '독립 기본값' }).click();
await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190');
await expect(page.getByLabel('월')).toHaveValue('1');
await page.getByRole('button', { name: '현재 게임 환경 적용' }).click();
await expect(page.getByLabel('연도', { exact: true })).toHaveValue('205');
await expect(page.getByLabel('월')).toHaveValue('8');
await page.getByRole('button', { name: '내 장수를 출병자로' }).click();
await expect(page.getByLabel('이름').first()).toHaveValue('유비');
await expect(page.getByLabel('병사').first()).toHaveValue('4321');
const battleButton = page.getByRole('button', { name: '전투', exact: true });
await battleButton.hover();
expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
await page.getByLabel('시드').fill('playwright-fixed-seed');
await battleButton.click();
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
await expect(page.getByText('5', { exact: true })).toBeVisible();
expect(fixture.pollingCount).toBe(2);
expect(fixture.requests).toContain('battle.getSimulation');
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'battle-simulator-core-desktop.png'),
fullPage: true,
animations: 'disabled',
});
}
});
test('keeps simulation available without a game general and preserves input after an API error', async ({ page }) => {
const fixture: Fixture = {
hasGeneral: false,
failNextSimulation: true,
pollingCount: 0,
requests: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 500, height: 900 });
await gotoSimulator(page);
await expect(page).toHaveURL(/battle-simulator/);
await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled();
await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled();
await page.getByLabel('시드').fill('keep-this-seed');
await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
const notice = page.getByLabel('시뮬레이터 데이터 안내');
expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'battle-simulator-core-mobile.png'),
fullPage: true,
animations: 'disabled',
});
}
});
@@ -0,0 +1,73 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test } from '@playwright/test';
const refBaseUrl = process.env.REF_BATTLE_SIM_URL;
const refPasswordFile = process.env.REF_USER_PASSWORD_FILE;
const refUsername = process.env.REF_USER_ID ?? 'refuser1';
const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR;
const refTest = refBaseUrl && refPasswordFile ? test : test.skip;
refTest('runs the legacy simulator in the same Chromium and captures its rendered contract', async ({ page }) => {
test.setTimeout(120_000);
if (!refBaseUrl || !refPasswordFile) {
throw new Error('REF_BATTLE_SIM_URL and REF_USER_PASSWORD_FILE are required');
}
const password = (await readFile(refPasswordFile, 'utf8')).trim();
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(refBaseUrl, { waitUntil: 'networkidle' });
await page.locator('#username').fill(refUsername);
await page.locator('#password').fill(password);
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const loginResponse = await page
.context()
.request.post(new URL('api.php?path=Login/LoginByID', refBaseUrl).toString(), {
data: { username: refUsername, password: passwordHash },
});
expect(loginResponse.status()).toBe(200);
await expect(loginResponse.json()).resolves.toMatchObject({ result: true });
await page.goto(new URL('hwe/battle_simulator.php', refBaseUrl).toString(), {
waitUntil: 'networkidle',
});
const battleButton = page.locator('.btn-begin_battle');
await expect(battleButton).toBeVisible();
const container = page.locator('#container');
const rect = await container.boundingBox();
expect(rect?.width).toBeGreaterThanOrEqual(995);
expect(rect?.width).toBeLessThanOrEqual(1005);
// A login with no game general leaves the legacy nation selects without a
// selected option. Choose the first legal independent value before running.
await page.locator('.form_nation_type').evaluateAll((elements) => {
for (const element of elements) {
const select = element as HTMLSelectElement;
select.selectedIndex = 0;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await expect(page.locator('.form_nation_type').first()).not.toHaveValue('');
await battleButton.hover();
expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
const simulationResponse = page.waitForResponse(
(response) => response.url().includes('/j_simulate_battle.php') && response.status() === 200,
{ timeout: 90_000 }
);
await battleButton.click();
await simulationResponse;
await expect(page.locator('#generalBattleResultLog')).not.toBeEmpty();
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'battle-simulator-ref-desktop.png'),
fullPage: true,
animations: 'disabled',
});
}
});
@@ -16,6 +16,8 @@ export default defineConfig({
'nationOffices.spec.ts',
'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts',
'battleSimulator.spec.ts',
'battleSimulatorRef.spec.ts',
],
fullyParallel: false,
workers: 1,
+1
View File
@@ -11,6 +11,7 @@
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"",
@@ -7,6 +7,7 @@ interface Props {
options: BattleSimOptions;
mode: 'attacker' | 'defender';
title: string;
canImportServer: boolean;
}
const props = defineProps<Props>();
@@ -59,7 +60,19 @@ const officerLevelOptions = [
<div class="general-subtitle">No {{ general.no }}</div>
</div>
<div class="general-actions">
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
<button
class="action"
type="button"
:disabled="!canImportServer"
:title="
canImportServer
? '게임 서버의 장수 정보를 불러옵니다.'
: '게임 장수를 보유해야 사용할 수 있습니다.'
"
@click="emit('import')"
>
서버에서 가져오기
</button>
<button class="action" type="button" @click="emit('save')">저장</button>
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
<button class="action" type="button" @click="triggerLoad">불러오기</button>
@@ -187,21 +200,11 @@ const officerLevelOptions = [
<div class="form-row">
<label class="field">
<span>훈련</span>
<input
v-model.number="general.train"
type="number"
min="40"
:max="options.config.maxTrainByWar"
/>
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
</label>
<label class="field">
<span>사기</span>
<input
v-model.number="general.atmos"
type="number"
min="40"
:max="options.config.maxAtmosByWar"
/>
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
</label>
<label class="field">
<span>전특</span>
@@ -308,30 +311,15 @@ const officerLevelOptions = [
<div class="form-row buff-row">
<label class="field">
<span>상대 회피</span>
<input
v-model.number="general.inheritBuff.warAvoidRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 필살</span>
<input
v-model.number="general.inheritBuff.warCriticalRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 계략</span>
<input
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
</label>
</div>
</div>
@@ -391,6 +379,11 @@ const officerLevelOptions = [
color: #f0b6b6;
}
.action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.form-block {
display: flex;
flex-direction: column;
-1
View File
@@ -197,7 +197,6 @@ const routes = [
component: BattleSimulatorView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
@@ -26,6 +26,7 @@ type BattleExport = {
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const loading = ref(true);
const error = ref<string | null>(null);
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
const generalList = ref<GeneralListResponse | null>(null);
const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(null);
const gameDefaults = ref<GeneralMeResponse>(null);
let generalIdSeed = 0;
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
try {
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
options.value = context;
gameDefaults.value = me;
year.value = context.world.currentYear;
month.value = context.world.currentMonth;
repeatCnt.value = 1;
@@ -283,6 +286,47 @@ const initializeDefaults = async () => {
}
};
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
const applyGameEnvironment = () => {
if (!options.value) {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
defenderNation.tech = attackerNation.tech;
attackerCity.level = me?.city?.level ?? 5;
defenderCity.level = attackerCity.level;
defenderCity.def = me?.city?.defence ?? 1000;
defenderCity.wall = me?.city?.wall ?? 1000;
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
defenderNation.isCapital = attackerNation.isCapital;
error.value = null;
};
const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
repeatCnt.value = 1;
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
attackerCity.level = 5;
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
error.value = null;
};
onMounted(() => {
void initializeDefaults();
});
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
}
isSimulating.value = true;
error.value = null;
if (action === 'battle') {
battleResult.value = null;
}
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
try {
const payload = buildBattlePayload(action);
const response = await trpc.battle.simulate.mutate(payload);
const result =
'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId);
'payload' in response && response.payload
? response.payload
: await waitForSimulationResult(response.jobId);
if (!result.result) {
error.value = result.reason || 'battle_failed';
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
};
const openImportModal = async (target: GeneralDraft) => {
if (!hasGameGeneral.value) {
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
return;
}
importTarget.value = target;
importOpen.value = true;
if (!generalList.value) {
@@ -801,47 +855,62 @@ const closeImportModal = () => {
importTarget.value = null;
};
const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
const response = await trpc.battle.getGeneralDetail.query({ generalId });
applyGeneralExport(target, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id);
};
const applyMyGeneralToAttacker = async () => {
const generalId = gameDefaults.value?.general?.id;
if (!attackerGeneral.value || !generalId) {
error.value = '불러올 내 장수가 없습니다.';
return;
}
try {
error.value = null;
await applyServerGeneral(attackerGeneral.value, generalId);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const confirmImport = async () => {
if (!importTarget.value || !selectedGeneralId.value) {
return;
}
try {
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
applyGeneralExport(importTarget.value, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
importTarget.value.no =
importTarget.value === attackerGeneral.value
? 1
: resolveGeneralNo(response.general.no, importTarget.value.id);
await applyServerGeneral(importTarget.value, selectedGeneralId.value);
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
{
label: '준 피해',
value: battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
value:
battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
},
{
label: '받은 피해',
value: battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
value:
battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
},
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
</header>
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<div>
<strong>게임 상태와 분리된 모의 계산</strong>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
</p>
</div>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
현재 게임 환경 적용
</button>
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
독립 기본값
</button>
<button
class="ghost"
type="button"
:disabled="!hasGameGeneral || !attackerGeneral"
@click="applyMyGeneralToAttacker"
>
장수를 출병자로
</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
:options="options!"
mode="attacker"
title="출병자 설정"
:can-import-server="hasGameGeneral"
@import="openImportModal(attackerGeneral!)"
@save="saveGeneral(attackerGeneral!)"
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
:options="options!"
mode="defender"
:title="`수비자 설정 ${index + 1}`"
:can-import-server="hasGameGeneral"
@import="openImportModal(defender)"
@save="saveGeneral(defender)"
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
<div v-else class="select-wrap">
<select v-model.number="selectedGeneralId">
<optgroup
v-for="group in generalGroups"
:key="group.nation.id"
:label="group.nation.name"
>
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
<option
v-for="general in group.generals"
:key="general.id"
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, 1000px);
margin: 0 auto;
padding-bottom: 30px;
background:
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
gap: 8px;
}
.independence-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid rgba(112, 170, 141, 0.45);
background: rgba(18, 52, 40, 0.35);
}
.independence-notice strong {
color: #bfe2cd;
font-size: 0.85rem;
}
.independence-notice p {
margin: 4px 0 0;
color: rgba(221, 239, 228, 0.75);
font-size: 0.75rem;
}
.notice-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
}
button {
font-family: inherit;
background: none;
@@ -1230,6 +1355,11 @@ button {
background: rgba(16, 16, 16, 0.6);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
@@ -1369,5 +1499,10 @@ button {
flex-direction: column;
align-items: flex-start;
}
.independence-notice {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+13 -10
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
@@ -281,6 +281,7 @@ onMounted(() => {
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
</div>
@@ -464,17 +465,13 @@ onMounted(() => {
<section v-else class="join-grid">
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
<template #actions>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
목록 새로고침
</button>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
</template>
<div v-if="npcError" class="muted">{{ npcError }}</div>
<div v-if="npcLoading && npcCandidates.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">
빙의 가능한 NPC가 없습니다.
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
<div v-else class="npc-list">
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<div class="npc-header">
@@ -490,9 +487,7 @@ onMounted(() => {
<div>나이 {{ npc.age }}</div>
<div>도시 {{ npc.city?.name ?? '-' }}</div>
</div>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
빙의
</button>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
</div>
</div>
<div class="npc-footer">
@@ -542,6 +537,14 @@ onMounted(() => {
font-size: 0.8rem;
}
.simulator-link {
border: 1px solid rgba(112, 170, 141, 0.55);
padding: 6px 10px;
color: #bfe2cd;
font-size: 0.8rem;
text-decoration: none;
}
.join-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
+131 -60
View File
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
});
const listItems = computed(() =>
list.value
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
: []
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
);
const info = computed(() => detail.value?.bettingInfo ?? null);
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
const totalAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
);
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
const pureAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce(
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
return result;
});
const usedAmount = computed(() =>
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
);
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const parseYearMonth = (yearMonth: number): [number, number] => [
Math.floor(yearMonth / 12),
(yearMonth % 12) + 1,
];
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
const readSelection = (value: string): number[] => {
try {
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
.map((index) => candidates.value[index]?.title ?? '-')
.join(', ');
const isListOpen = (item: BettingListItem): boolean =>
!item.finished && listYearMonth.value <= item.closeYearMonth;
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
const isDetailOpen = computed(() =>
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
};
const expectedMultiplier = (key: string, betAmount: number): string => {
if (betAmount <= 0) {
return '0.0';
const rewardByMatch = computed(() => {
const selectCount = info.value?.selectCnt ?? 0;
const rewards = new Array<number>(selectCount + 1).fill(0);
if (selectCount <= 0) {
return rewards;
}
const amountByMatch = new Map<number, number>();
for (const [key, betAmount] of detailRows.value) {
const matched = matchCount(key);
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
}
if (selectCount === 1 || info.value?.isExclusive) {
rewards[selectCount] = totalAmount.value;
return rewards;
}
let remainingReward = totalAmount.value;
let accumulatedReward = 0;
let nextReward = totalAmount.value;
for (let matched = selectCount; matched > 0; matched -= 1) {
nextReward /= 2;
accumulatedReward += nextReward;
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] = accumulatedReward;
remainingReward -= accumulatedReward;
accumulatedReward = 0;
}
for (let matched = selectCount; matched >= 0; matched -= 1) {
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] += remainingReward;
break;
}
return rewards;
});
const expectedReward = (key: string): number => {
if (!info.value?.finished) {
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
return (reward / betAmount).toFixed(1);
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
}
const matched = matchCount(key);
const matchedAmount = detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
.reduce((sum, [, value]) => sum + value, 0);
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
return rewardByMatch.value[matchCount(key)] ?? 0;
};
const rewardDivisor = (key: string, betAmount: number): number =>
info.value?.finished
? detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
.reduce((sum, [, value]) => sum + value, 0)
: betAmount;
const expectedMultiplier = (key: string, betAmount: number): string => {
const divisor = rewardDivisor(key, betAmount);
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
};
const myExpectedReward = (key: string, betAmount: number): string => {
const myAmount = myBetMap.value.get(key);
if (myAmount === undefined) {
return '';
}
const divisor = rewardDivisor(key, betAmount);
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
};
const loadList = async () => {
@@ -295,7 +338,7 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
<div></div>
<h1>국가 베팅장</h1>
<div></div>
<div></div>
@@ -309,32 +352,37 @@ onMounted(() => {
{{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="currentYearMonth <= info.closeYearMonth">
({{ parseYearMonth(info.closeYearMonth)[0] }}
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(info.closeYearMonth)[0] }} {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
</div>
<div class="betting-candidates">
<button
<div
v-for="(candidate, index) in candidates"
:key="`${info.id}-${index}`"
type="button"
class="betting-candidate"
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
class="betting-candidate-cell"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
<button
type="button"
class="betting-candidate"
:class="{
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
}"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
</div>
</div>
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
@@ -361,7 +409,7 @@ onMounted(() => {
{{ selectionLabel(key) }}
</div>
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
<div>{{ myExpectedReward(key, betAmount) }}</div>
<div>{{ expectedMultiplier(key, betAmount) }}</div>
</div>
</div>
@@ -382,8 +430,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.finished">(종료)</span>
<span v-else-if="isListOpen(item)">
({{ parseYearMonth(item.closeYearMonth)[0] }}
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(item.closeYearMonth)[0] }} {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
</button>
@@ -400,12 +447,11 @@ onMounted(() => {
.nation-betting-page {
position: relative;
width: 500px;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.3;
line-height: 1.5;
overflow-x: hidden;
}
@@ -458,19 +504,32 @@ onMounted(() => {
}
.section-title {
min-height: 22px;
min-height: 21px;
text-align: center;
line-height: 22px;
line-height: 21px;
}
.betting-candidates {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
margin-top: -3.5px;
margin-right: -1.75px;
margin-left: -1.75px;
}
.betting-candidate-cell {
flex: 0 0 auto;
width: 33.33333333%;
max-width: 100%;
padding-right: 1.75px;
padding-left: 1.75px;
margin-top: 3.5px;
}
.betting-candidate {
width: 100%;
height: 100%;
min-height: 143px;
min-width: 0;
padding: 0;
border: 1px solid gray;
@@ -530,7 +589,7 @@ onMounted(() => {
.betting-form input {
grid-column: span 4;
min-width: 0;
height: 30px;
height: 35.5px;
border: 1px solid #777;
background: #ddd;
color: #303030;
@@ -538,10 +597,11 @@ onMounted(() => {
.betting-form button {
grid-column: span 2;
height: 35.5px;
}
.payout-table {
margin-top: 6px;
margin-top: 0;
}
.payout-row {
@@ -551,7 +611,7 @@ onMounted(() => {
.payout-row > div {
min-width: 0;
padding: 2px 4px;
padding: 0 4px;
}
.payout-row > div:not(:first-child) {
@@ -572,7 +632,7 @@ onMounted(() => {
.betting-item {
display: block;
width: 100%;
width: auto;
margin: 0.25em;
border: 0;
background: transparent;
@@ -595,6 +655,7 @@ onMounted(() => {
.betting-footer .legacy-nav-button {
width: 90px;
height: 35.5px;
}
.betting-notice,
@@ -602,6 +663,15 @@ onMounted(() => {
padding: 6px 10px;
}
.betting-notice {
position: fixed;
top: 8px;
right: 8px;
z-index: 20;
width: min(320px, calc(100vw - 16px));
background: #303030;
}
.betting-notice.error {
border: 1px solid #9b4848;
color: #ffd0d0;
@@ -617,8 +687,9 @@ onMounted(() => {
width: 1000px;
}
.betting-candidates {
grid-template-columns: repeat(6, minmax(0, 1fr));
.betting-candidate-cell {
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
width: 16.66666667%;
}
.betting-form {
+1
View File
@@ -817,6 +817,7 @@ export const adminRouter = router({
profileName: profile.profileName,
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
}));
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
korName: string;
@@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
private mapProfile(
row: GatewayProfileRecord,
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
runtimeMap: Map<
string,
{ apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean }
>
): LobbyProfileStatus {
const meta = row.meta;
return {
@@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
runtime: runtimeMap.get(row.profileName) ?? {
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
korName: (meta.korName as string | undefined) ?? row.profile,
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
export interface ProfileRuntimeState {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
}
@@ -66,13 +67,19 @@ export const planProfileReconcile = (
): { shouldStart: boolean; shouldStop: boolean } => {
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
return {
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
shouldStart: !(
runtime.apiRunning &&
runtime.daemonRunning &&
runtime.battleSimRunning &&
runtime.tournamentRunning
),
shouldStop: false,
};
}
return {
shouldStart: false,
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
shouldStop:
runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning,
};
};
@@ -273,8 +280,16 @@ const parseInstallOptions = (
};
};
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string =>
`sammo:${profileName}:${
role === 'api'
? 'game-api'
: role === 'daemon'
? 'turn-daemon'
: role === 'battle-sim'
? 'battle-sim-worker'
: 'tournament-worker'
}`;
const isMissingProcessError = (error: unknown): boolean =>
error instanceof Error && /process or namespace not found/i.test(error.message);
@@ -285,11 +300,13 @@ export const buildProcessDefinitions = (
): {
api: { name: string; script: string; cwd: string; env: Record<string, string> };
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
battleSim: { name: string; script: string; cwd: string; env: Record<string, string> };
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
} => {
const baseEnv = { ...(config.baseEnv ?? {}) };
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
@@ -327,6 +344,15 @@ export const buildProcessDefinitions = (
cwd: daemonCwd,
env: daemonEnv,
},
battleSim: {
name: battleSimName,
script: apiScript,
cwd: apiCwd,
env: {
...apiEnv,
GAME_API_ROLE: 'battle-sim-worker',
},
},
tournament: {
name: tournamentName,
script: apiScript,
@@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
profileNames.map((profileName) => {
const apiName = buildProcessName(profileName, 'api');
const daemonName = buildProcessName(profileName, 'daemon');
const battleSimName = buildProcessName(profileName, 'battle-sim');
const tournamentName = buildProcessName(profileName, 'tournament');
return {
profileName,
apiRunning: processNames.get(apiName) ?? false,
daemonRunning: processNames.get(daemonName) ?? false,
battleSimRunning: processNames.get(battleSimName) ?? false,
tournamentRunning: processNames.get(tournamentName) ?? false,
};
});
@@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
try {
await this.processManager.start(definitions.api);
await this.processManager.start(definitions.daemon);
await this.processManager.start(definitions.battleSim);
await this.processManager.start(definitions.tournament);
await this.repository.updateLastError(profile.profileName, null);
return true;
@@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
const failures: string[] = [];
for (const name of [apiName, daemonName, tournamentName]) {
for (const name of [apiName, daemonName, battleSimName, tournamentName]) {
if (!existingNames.has(name)) {
continue;
}
@@ -88,6 +88,7 @@ const createHarness = (
? [
{ name: 'sammo:che:2:game-api', status: 'online' },
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
{ name: 'sammo:che:2:battle-sim-worker', status: 'online' },
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
]
: [],
@@ -138,7 +139,7 @@ const createHarness = (
};
describe('GatewayOrchestrator first-class operations', () => {
it('starts both profile processes and records success', async () => {
it('starts every profile process and records success', async () => {
const harness = createHarness(buildOperation('START'));
await harness.orchestrator.runOperationsNow();
@@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.started.map((definition) => definition.name)).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('stops both profile processes and records success', async () => {
it('stops every profile process and records success', async () => {
const harness = createHarness(buildOperation('STOP'));
await harness.orchestrator.runOperationsNow();
@@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.stopped).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
@@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
@@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.completions).toEqual(['FAILED']);
});
it('attempts to stop both roles before reporting a partial PM2 failure', async () => {
it('attempts to stop every role before reporting a partial PM2 failure', async () => {
const harness = createHarness(buildOperation('STOP'), false, true);
await harness.orchestrator.runOperationsNow();
@@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.stopped).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['FAILED']);
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RUNNING', {
apiRunning: true,
daemonRunning: false,
battleSimRunning: true,
tournamentRunning: true,
})
).toEqual({ shouldStart: true, shouldStop: false });
@@ -39,6 +40,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('PREOPEN', {
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
})
).toEqual({ shouldStart: true, shouldStop: false });
@@ -49,6 +51,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RUNNING', {
apiRunning: true,
daemonRunning: true,
battleSimRunning: true,
tournamentRunning: true,
})
).toEqual({ shouldStart: false, shouldStop: false });
@@ -59,6 +62,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('STOPPED', {
apiRunning: false,
daemonRunning: true,
battleSimRunning: false,
tournamentRunning: false,
})
).toEqual({ shouldStart: false, shouldStop: true });
@@ -69,6 +73,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RESERVED', {
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
})
).toEqual({ shouldStart: false, shouldStop: false });
@@ -95,6 +100,11 @@ describe('buildProcessDefinitions', () => {
});
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
expect(definitions.battleSim).toMatchObject({
cwd: path.join(buildWorkspace, 'app', 'game-api'),
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
env: { GAME_API_ROLE: 'battle-sim-worker' },
});
expect(definitions.tournament).toMatchObject({
cwd: path.join(buildWorkspace, 'app', 'game-api'),
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
@@ -107,6 +117,7 @@ describe('buildProcessDefinitions', () => {
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
});
});
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
profileName: 'che:2',
apiRunning: runtimeRunning,
daemonRunning: runtimeRunning,
battleSimRunning: runtimeRunning,
tournamentRunning: runtimeRunning,
},
});
+3 -1
View File
@@ -70,6 +70,7 @@ type AdminProfile = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
buildCommitSha?: string;
@@ -1352,7 +1353,8 @@ onMounted(() => {
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM:
{{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
</div>
</div>
@@ -16,7 +16,12 @@ type Profile = {
buildWorkspace?: string;
buildError?: string;
lastError?: string;
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
};
type Scenario = {
@@ -377,6 +382,14 @@ onBeforeUnmount(() => {
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Battle sim worker</div>
<div
:class="selectedProfile.runtime.battleSimRunning ? 'text-emerald-400' : 'text-zinc-500'"
>
{{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Tournament worker</div>
<div
@@ -33,6 +33,9 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
비교한다.
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
않아 동적 호환 판정에서 제외한다.
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
@@ -214,6 +214,11 @@ Four missing-target cases cover nonexistent general IDs for gift and
employment plus nonexistent city IDs for spying and movement. Both engines
reject the requested command, execute rest without command RNG, and produce
the same semantic state delta.
Thirteen resource argument and balance cases cover 100-unit rounding and
minimum/maximum clamps for gift, donation, and rice trade; donation against
available and minimum resources; and gift reserve and self-target rejection.
They compare normalized last-turn arguments, RNG, fallback, and semantic state
deltas.
Requests that omit the required argument object remain unverified because the
reference runner did not terminate within the bounded comparison run.
This is not yet a claim that every command-specific
+16 -2
View File
@@ -21,6 +21,9 @@ NPC list, including mutations and recoverable API failures.
betting routes, including a recoverable failed bet.
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
명예의 전당 computed DOM without embedding the reference password.
`battleSimulator.spec.ts` covers the authenticated simulator with and without
an owned general. `battleSimulatorRef.spec.ts` can additionally exercise the
live reference page when its URL, user, and ignored password file are supplied.
Run the suite from the core2026 repository root:
@@ -33,6 +36,16 @@ When another worktree occupies the default ports, set
`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
tournament and betting screenshots.
Run the focused simulator fixture with:
```sh
pnpm --filter game-frontend test:e2e:battle-simulator
```
The optional reference check uses `REF_BATTLE_SIM_URL`, `REF_USER_ID`, and
`REF_USER_PASSWORD_FILE`. The password is read inside the test and is never
written to screenshots or reports.
The suite starts both applications at their public prefixes:
- gateway: `http://127.0.0.1:15100/gateway/`
@@ -53,8 +66,8 @@ storage, route guards, and image loading.
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
@@ -62,6 +75,7 @@ storage, route guards, and image loading.
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, fixed-seed result/logs, retained input after API error |
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
@@ -51,6 +51,8 @@ export interface TurnCommandEnv {
initialAllowedTechLevel?: number;
baseGold: number;
baseRice: number;
generalMinimumGold?: number;
generalMinimumRice?: number;
maxResourceActionAmount: number;
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
generalActionModules?: Array<GeneralActionModule>;
@@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js';
export interface TradeEnvironment {
exchangeFee?: number;
@@ -46,8 +47,10 @@ export class ActionDefinition<
if (!parsed || !Number.isFinite(parsed.amount)) {
return null;
}
const maxAmount = this.env.maxResourceActionAmount ?? 10_000;
const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount));
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount ?? 10_000);
if (amount === null) {
return null;
}
return { buyRice: parsed.buyRice, amount };
}
@@ -1,6 +1,7 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
denyWithReason,
existsDestGeneral,
friendlyDestGeneral,
notBeNeutral,
@@ -19,15 +20,13 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js';
const ACTION_NAME = '증여';
const ACTION_KEY = 'che_증여';
const ARGS_SCHEMA = z.object({
isGold: z.boolean(),
amount: z.preprocess(
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
z.number().int().positive()
),
amount: z.number(),
destGeneralID: z.number().int().positive(),
});
export type GiftArgs = z.infer<typeof ARGS_SCHEMA>;
@@ -51,10 +50,13 @@ export class ActionDefinition<
if (!parsed) {
return null;
}
const maxAmount = this.env.maxResourceActionAmount > 0 ? this.env.maxResourceActionAmount : 10000;
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount);
if (amount === null) {
return null;
}
return {
...parsed,
amount: Math.max(100, Math.min(parsed.amount, maxAmount)),
amount,
};
}
@@ -62,9 +64,12 @@ export class ActionDefinition<
return [notBeNeutral(), occupiedCity(), suppliedCity()];
}
buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] {
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
buildConstraints(ctx: ConstraintContext, args: GiftArgs): Constraint[] {
if (ctx.actorId === args.destGeneralID) {
return [denyWithReason('본인입니다')];
}
const minGold = this.env.generalMinimumGold ?? 0;
const minRice = this.env.generalMinimumRice ?? 500;
return [
notBeNeutral(),
@@ -83,8 +88,8 @@ export class ActionDefinition<
throw new Error('증여 대상 장수가 없습니다.');
}
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
const minGold = this.env.generalMinimumGold ?? 0;
const minRice = this.env.generalMinimumRice ?? 500;
const resKey = args.isGold ? 'gold' : 'rice';
const resName = args.isGold ? '금' : '쌀';
@@ -21,6 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js';
const ACTION_NAME = '헌납';
const ACTION_KEY = 'che_헌납';
@@ -96,12 +97,23 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor() {
constructor(private readonly env: TurnCommandEnv) {
this.resolver = new ActionResolver();
}
parseArgs(raw: unknown): DonateArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw);
if (!parsed) {
return null;
}
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount);
if (amount === null) {
return null;
}
return {
...parsed,
amount,
};
}
buildMinConstraints(_ctx: ConstraintContext, _args: DonateArgs): Constraint[] {
@@ -109,10 +121,12 @@ export class ActionDefinition<
}
buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] {
const minGold = this.env.generalMinimumGold ?? 0;
const minRice = this.env.generalMinimumRice ?? 500;
if (args.isGold) {
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => args.amount)];
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => minGold)];
}
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => args.amount)];
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => minRice)];
}
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
@@ -128,5 +142,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
reqArg: true,
availabilityArgs: { isGold: true, amount: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -0,0 +1,14 @@
const DEFAULT_MIN_RESOURCE_ACTION_AMOUNT = 100;
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10_000;
const RESOURCE_ACTION_AMOUNT_UNIT = 100;
export const normalizeResourceActionAmount = (amount: number, configuredMaxAmount: number): number | null => {
if (!Number.isFinite(amount)) {
return null;
}
const maxAmount = configuredMaxAmount > 0 ? configuredMaxAmount : DEFAULT_MAX_RESOURCE_ACTION_AMOUNT;
const roundedAmount = Math.round(amount / RESOURCE_ACTION_AMOUNT_UNIT) * RESOURCE_ACTION_AMOUNT_UNIT;
return Math.max(DEFAULT_MIN_RESOURCE_ACTION_AMOUNT, Math.min(roundedAmount, maxAmount));
};
@@ -0,0 +1,237 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/';
const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL;
const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1';
const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting');
if (!staticBaseUrl && !passwordFile) {
throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.');
}
const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : '';
const bettingList = {
result: true,
bettingList: {
7: {
id: 7,
type: 'bettingNation',
name: '천통국 예상',
finished: false,
selectCnt: 2,
isExclusive: false,
reqInheritancePoint: true,
openYearMonth: 2316,
closeYearMonth: 2340,
winner: null,
totalAmount: 800,
},
},
year: 193,
month: 1,
};
const bettingDetail = {
result: true,
bettingInfo: {
id: 7,
type: 'bettingNation',
name: '천통국 예상',
finished: false,
selectCnt: 2,
isExclusive: false,
reqInheritancePoint: true,
openYearMonth: 2316,
closeYearMonth: 2340,
candidates: [
{ title: '촉', info: '국력: 1200<br>장수 수: 8<br>도시 수: 5', isHtml: true },
{ title: '위', info: '국력: 1100<br>장수 수: 7<br>도시 수: 4', isHtml: true },
{ title: '오', info: '국력: 900<br>장수 수: 6<br>도시 수: 3', isHtml: true },
{ title: '연', info: '국력: 700<br>장수 수: 5<br>도시 수: 2', isHtml: true },
{ title: '양', info: '국력: 650<br>장수 수: 4<br>도시 수: 2', isHtml: true },
{ title: '형', info: '국력: 600<br>장수 수: 3<br>도시 수: 1', isHtml: true },
],
winner: null,
},
bettingDetail: [
['[-1]', 500],
['[0,1]', 200],
['[1,2]', 100],
],
myBetting: [['[0,1]', 50]],
remainPoint: 1200,
year: 193,
month: 1,
};
const login = async (context, page) => {
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
const globalSalt = await page.locator('#global_salt').inputValue();
// The reference entrance polls install status and can keep the PHP session
// occupied. Leave it before the login request so the session lock is free.
await page.goto('about:blank');
await context.clearCookies();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
timeout: 60_000,
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
};
const installBettingFixture = async (page) => {
await page.route('**/api.php*', async (route) => {
const path = new URL(route.request().url()).searchParams.get('path');
if (path === 'Betting/GetBettingList') {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) });
return;
}
if (path === 'Betting/GetBettingDetail') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(bettingDetail),
});
return;
}
await route.continue();
});
};
const mountStaticReference = async (page) => {
const hweUrl = new URL('hwe/', staticBaseUrl);
const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl);
await page.setContent(
`<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=500">
<base href="${hweUrl}">
<link rel="stylesheet" href="${new URL('d_shared/common.css', hweUrl)}">
<link rel="stylesheet" href="${new URL('vendors.css', assetUrl)}">
<link rel="stylesheet" href="${new URL('common_ts.css', assetUrl)}">
<link rel="stylesheet" href="${new URL('bootstrap.css', assetUrl)}">
<link rel="stylesheet" href="${new URL('v_nationBetting.css', assetUrl)}">
</head>
<body>
<div id="app"></div>
<script src="${new URL('d_shared/common_path.js', hweUrl)}"></script>
<script src="${new URL('vendors.js', assetUrl)}"></script>
<script src="${new URL('common_ts.js', assetUrl)}"></script>
<script src="${new URL('bootstrap.js', assetUrl)}"></script>
<script src="${new URL('v_nationBetting.js', assetUrl)}"></script>
</body>
</html>`,
{ waitUntil: 'networkidle' }
);
};
const measure = async (browser, viewport) => {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
timezoneId: 'UTC',
ignoreHTTPSErrors: true,
});
try {
const page = await context.newPage();
await installBettingFixture(page);
if (staticBaseUrl) {
await mountStaticReference(page);
} else {
await login(context, page);
await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), {
waitUntil: 'networkidle',
timeout: 60_000,
});
}
await page.locator('.bettingItem').click();
await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' });
const geometry = await page.locator('#container').evaluate((container) => {
const rect = (element) => {
const value = element.getBoundingClientRect();
return { x: value.x, y: value.y, width: value.width, height: value.height };
};
const cards = Array.from(container.querySelectorAll('.bettingCandidate'));
const firstCard = cards[0];
const cardStyle = getComputedStyle(firstCard);
const titleStyle = getComputedStyle(firstCard.querySelector('.title'));
const optionalRect = (selector) => {
const element = container.querySelector(selector);
return element ? rect(element) : null;
};
return {
container: rect(container),
topBar: rect(container.querySelector('.back_bar')),
candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect),
candidates: cards.map(rect),
bettingForm: optionalRect('.bettingCandidates + .row'),
payoutTable: optionalRect('.bettingCandidates + .row + div'),
bettingList: optionalRect('.bettingList'),
bottomBar: optionalRect('.bottom_bar, .bg0[style]'),
cardStyle: {
borderWidth: cardStyle.borderWidth,
borderRadius: cardStyle.borderRadius,
cursor: cardStyle.cursor,
fontSize: cardStyle.fontSize,
lineHeight: cardStyle.lineHeight,
},
titleStyle: {
fontWeight: titleStyle.fontWeight,
textAlign: titleStyle.textAlign,
},
};
});
await page.locator('.bettingCandidate').first().click();
const pickedStyle = await page
.locator('.bettingCandidate')
.first()
.evaluate((candidate) => {
const style = getComputedStyle(candidate);
return {
borderColor: style.borderColor,
outlineWidth: style.outlineWidth,
titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight,
};
});
const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' });
return { geometry, pickedStyle, screenshotPath };
} finally {
await context.close();
}
};
await mkdir(artifactRoot, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const result = {};
for (const viewport of [
{ name: 'desktop', width: 1280, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
result[viewport.name] = await measure(browser, viewport);
}
const outputPath = resolve(artifactRoot, 'computed-dom.json');
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`);
} finally {
await browser.close();
}
@@ -4,10 +4,7 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const imageRoots = [
resolve(repositoryRoot, '../image/game'),
resolve(repositoryRoot, '../../image/game'),
];
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const response = (data: unknown) => ({ result: { data } });
@@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
const containerRect = container.getBoundingClientRect();
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
const detail = container.querySelector<HTMLElement>('.betting-detail')!;
const detailRect = detail.getBoundingClientRect();
const candidateRowElement = container.querySelector<HTMLElement>('.betting-candidates')!;
const candidateRow = candidateRowElement.getBoundingClientRect();
const candidateCells = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate-cell'));
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
const cardStyle = getComputedStyle(cards[0]!);
const optionalRect = (selector: string) => {
const element = container.querySelector<HTMLElement>(selector);
if (!element) return null;
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
return {
container: { x: containerRect.x, width: containerRect.width },
container: { x: containerRect.x, width: containerRect.width, height: containerRect.height },
bar: { width: bar.width, height: bar.height },
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
detail: { x: detailRect.x, width: detailRect.width },
candidateRow: {
x: candidateRow.x,
width: candidateRow.width,
},
candidateCells: candidateCells.map((cell) => {
const rect = cell.getBoundingClientRect();
return { x: rect.x, width: rect.width };
}),
cards: cards.map((card) => {
const rect = card.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}),
bettingForm: optionalRect('.betting-form'),
payoutTable: optionalRect('.payout-table'),
bettingList: optionalRect('.betting-list'),
bottomBar: optionalRect('.betting-footer'),
cardStyle: {
borderWidth: cardStyle.borderWidth,
borderRadius: cardStyle.borderRadius,
@@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
},
};
});
expect(geometry.container).toEqual({ x: 140, width: 1000 });
expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 });
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true);
expect(geometry.detail).toEqual({ x: 140, width: 1000 });
expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 });
expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25));
expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75));
expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143));
expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53));
expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 });
expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 });
expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 });
expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 });
expect(geometry.cardStyle).toEqual({
borderWidth: '1px',
borderRadius: '7px',
cursor: 'pointer',
fontSize: '14px',
lineHeight: '18.2px',
lineHeight: '21px',
});
await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1);
await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText(
'(50 -> 100.0)'
);
await page.locator('.betting-candidate').nth(0).click();
await page.locator('.betting-candidate').nth(1).click();
const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => {
const style = getComputedStyle(candidate);
return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight };
});
const pickedStyle = await page
.locator('.betting-candidate')
.first()
.evaluate((candidate) => {
const style = getComputedStyle(candidate);
return {
borderColor: style.borderColor,
outlineWidth: style.outlineWidth,
titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight,
};
});
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
expect(pickedStyle.outlineWidth).toBe('1px');
@@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
return {
x: rect.x,
width: rect.width,
firstX: cards[0]!.getBoundingClientRect().x,
firstWidth: cards[0]!.getBoundingClientRect().width,
fourthY: cards[3]!.getBoundingClientRect().y,
firstY: cards[0]!.getBoundingClientRect().y,
@@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
});
expect(geometry.x).toBe(0);
expect(geometry.width).toBe(500);
expect(geometry.firstWidth).toBeCloseTo(161.328125, 3);
expect(geometry.firstX).toBe(0);
expect(geometry.firstWidth).toBe(164.328125);
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
if (artifactRoot) {
@@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
expect(geometry.tableWidth).toBe(1000);
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
expect(geometry.headerWidths).toEqual([
104.609375,
104.609375,
69.734375,
121.015625,
69.734375,
90.25,
69.734375,
69.734375,
69.734375,
69.734375,
80,
104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80,
80.109375,
]);
expect(geometry.headerStyle).toEqual({
@@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
lineHeight: '18.2px',
});
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS(
'color',
'rgb(135, 206, 235)'
);
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
await personality.hover();
await expect(personality.getByRole('tooltip')).toBeVisible();
@@ -348,6 +348,8 @@ const buildWorldInput = (
maxGeneral: 500,
baseGold: 0,
baseRice: 2_000,
generalMinimumGold: 0,
generalMinimumRice: 500,
maxResourceActionAmount: 10_000,
maxTechLevel: 12,
maxLevel: 255,
@@ -1109,6 +1109,194 @@ integration('general command missing-target fallback matrix', () => {
);
});
const resourceAmountCases: Array<{
name: string;
action: string;
args: Record<string, unknown>;
expectedAmount: number;
}> = [
{
name: 'gift rounds a half unit up',
action: 'che_증여',
args: { isGold: true, amount: 150, destGeneralID: 3 },
expectedAmount: 200,
},
{
name: 'gift clamps below the minimum',
action: 'che_증여',
args: { isGold: true, amount: 1, destGeneralID: 3 },
expectedAmount: 100,
},
{
name: 'gift clamps above the maximum',
action: 'che_증여',
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
expectedAmount: 10_000,
},
{
name: 'donation rounds a half unit up',
action: 'che_헌납',
args: { isGold: true, amount: 150 },
expectedAmount: 200,
},
{
name: 'donation clamps below the minimum',
action: 'che_헌납',
args: { isGold: true, amount: 1 },
expectedAmount: 100,
},
{
name: 'donation clamps above the maximum',
action: 'che_헌납',
args: { isGold: true, amount: 10_050 },
expectedAmount: 10_000,
},
{
name: 'trade rounds a half unit up',
action: 'che_군량매매',
args: { buyRice: true, amount: 150 },
expectedAmount: 200,
},
{
name: 'trade clamps below the minimum',
action: 'che_군량매매',
args: { buyRice: true, amount: 1 },
expectedAmount: 100,
},
{
name: 'trade clamps above the maximum',
action: 'che_군량매매',
args: { buyRice: true, amount: 10_050 },
expectedAmount: 10_000,
},
];
integration('general command resource amount normalization matrix', () => {
it.each(resourceAmountCases)(
'$name matches legacy rounding and clamp semantics',
async ({ action, args, expectedAmount }) => {
const request = buildRequest(action, args);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
const coreActor = core.after.generals.find((entry) => entry.id === 1);
const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
const coreLastTurn = coreActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount });
expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount });
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
integration('general command donation resource boundaries', () => {
it('donates the available resource when the normalized request exceeds the current amount', async () => {
const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_헌납',
actionKey: 'che_헌납',
usedFallback: false,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
it('falls back when current rice is below the legacy minimum even for a small request', async () => {
const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: false });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_헌납',
actionKey: '휴식',
usedFallback: true,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
});
integration('general command gift resource and target boundaries', () => {
it('keeps the legacy minimum rice reserve while gifting the available amount', async () => {
const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_증여',
actionKey: 'che_증여',
usedFallback: false,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
it('rejects gifting to the actor and falls back without command RNG', async () => {
const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: false });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_증여',
actionKey: '휴식',
usedFallback: true,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
});
type GeneralConstraintCase = {
name: string;
action: string;