Merge branch 'main' into feature/ingame-message-parity
This commit is contained in:
@@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes
|
|||||||
import { processBattleSimJob } from './processor.js';
|
import { processBattleSimJob } from './processor.js';
|
||||||
|
|
||||||
export class InMemoryBattleSimTransport {
|
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 jobId = crypto.randomUUID();
|
||||||
const result = processBattleSimJob(payload);
|
const result = processBattleSimJob(payload);
|
||||||
this.results.set(jobId, result);
|
this.results.set(jobId, { requesterUserId, payload: result });
|
||||||
return { status: 'completed', jobId, payload: result };
|
return { status: 'completed', jobId, payload: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
return this.results.get(jobId) ?? null;
|
const result = this.results.get(jobId);
|
||||||
|
if (!result || result.requesterUserId !== requesterUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return result.payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,16 +44,16 @@ export class RedisBattleSimTransport {
|
|||||||
this.resultTtlSeconds = options.resultTtlSeconds;
|
this.resultTtlSeconds = options.resultTtlSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildResultKey(jobId: string): string {
|
private buildResultKey(jobId: string, requesterUserId: string): string {
|
||||||
return `${this.keys.resultKeyPrefix}${jobId}`;
|
return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildNotifyKey(jobId: string): string {
|
private buildNotifyKey(jobId: string, requesterUserId: string): string {
|
||||||
return `${this.keys.notifyKeyPrefix}${jobId}`;
|
return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
const raw = await this.client.get(this.buildResultKey(jobId));
|
const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId));
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,44 +64,49 @@ export class RedisBattleSimTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
|
private async waitForResult(
|
||||||
const existing = await this.readResult(jobId);
|
jobId: string,
|
||||||
|
requesterUserId: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<BattleSimResultPayload | null> {
|
||||||
|
const existing = await this.readResult(jobId, requesterUserId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyKey = this.buildNotifyKey(jobId);
|
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||||
const timeoutSec = toTimeoutSeconds(timeoutMs);
|
const timeoutSec = toTimeoutSeconds(timeoutMs);
|
||||||
const signal = await this.client.blPop(notifyKey, timeoutSec);
|
const signal = await this.client.blPop(notifyKey, timeoutSec);
|
||||||
if (!parseBlPopValue(signal)) {
|
if (!parseBlPopValue(signal)) {
|
||||||
return null;
|
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 jobId = crypto.randomUUID();
|
||||||
const job = {
|
const job = {
|
||||||
jobId,
|
jobId,
|
||||||
|
requesterUserId,
|
||||||
requestedAt: new Date().toISOString(),
|
requestedAt: new Date().toISOString(),
|
||||||
payload,
|
payload,
|
||||||
};
|
};
|
||||||
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
|
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) {
|
if (result) {
|
||||||
return { status: 'completed', jobId, payload: result };
|
return { status: 'completed', jobId, payload: result };
|
||||||
}
|
}
|
||||||
return { status: 'queued', jobId };
|
return { status: 'queued', jobId };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
return this.readResult(jobId);
|
return this.readResult(jobId, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
|
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
|
||||||
const resultKey = this.buildResultKey(jobId);
|
const resultKey = this.buildResultKey(jobId, requesterUserId);
|
||||||
const notifyKey = this.buildNotifyKey(jobId);
|
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||||
await this.client.set(resultKey, JSON.stringify(payload), {
|
await this.client.set(resultKey, JSON.stringify(payload), {
|
||||||
EX: this.resultTtlSeconds,
|
EX: this.resultTtlSeconds,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
||||||
|
|
||||||
export interface BattleSimTransport {
|
export interface BattleSimTransport {
|
||||||
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
|
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
|
||||||
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
|
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
|
|||||||
|
|
||||||
export interface BattleSimJob {
|
export interface BattleSimJob {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
requesterUserId: string;
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
payload: BattleSimJobPayload;
|
payload: BattleSimJobPayload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
|
|||||||
return result.element ?? 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 config = resolveGameApiConfigFromEnv();
|
||||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
await redis.connect();
|
await redis.connect();
|
||||||
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
|
|||||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleExit = async () => {
|
let stopped = options.signal?.aborted ?? false;
|
||||||
await redis.disconnect();
|
const handleExit = () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
const handleAbort = () => {
|
||||||
|
stopped = true;
|
||||||
};
|
};
|
||||||
process.on('SIGINT', handleExit);
|
process.on('SIGINT', handleExit);
|
||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
|
options.signal?.addEventListener('abort', handleAbort, { once: true });
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
const item = await redis.client.blPop(keys.queueKey, 0);
|
while (!stopped) {
|
||||||
const raw = parseBlPopValue(item);
|
// A finite block lets SIGTERM and test AbortSignal stop the worker without
|
||||||
if (!raw) {
|
// leaving a Redis operation or a detached lifecycle process behind.
|
||||||
continue;
|
const item = await redis.client.blPop(keys.queueKey, 1);
|
||||||
}
|
const raw = parseBlPopValue(item);
|
||||||
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let job: BattleSimJob | null = null;
|
let job: BattleSimJob | null = null;
|
||||||
try {
|
try {
|
||||||
job = JSON.parse(raw) as BattleSimJob;
|
job = JSON.parse(raw) as BattleSimJob;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = processBattleSimJob(job.payload);
|
const result = processBattleSimJob(job.payload);
|
||||||
await transport.pushResult(job.jobId, result);
|
await transport.pushResult(job.jobId, job.requesterUserId, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
||||||
await transport.pushResult(job.jobId, {
|
await transport.pushResult(job.jobId, job.requesterUserId, {
|
||||||
result: false,
|
result: false,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
process.off('SIGINT', handleExit);
|
||||||
|
process.off('SIGTERM', handleExit);
|
||||||
|
options.signal?.removeEventListener('abort', handleAbort);
|
||||||
|
await redis.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { getDexLevel } from '@sammo-ts/logic';
|
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 { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
||||||
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => {
|
|||||||
return value;
|
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 resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
|
||||||
const expLevel = meta.explevel ?? meta.expLevel;
|
const expLevel = meta.explevel ?? meta.expLevel;
|
||||||
if (typeof expLevel === 'number' && Number.isFinite(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({
|
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();
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
if (!worldState) {
|
if (!worldState) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -58,10 +66,10 @@ export const battleRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
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 }) => {
|
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
||||||
const result = await ctx.battleSim.getSimulationResult(input.jobId);
|
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return { status: 'queued', jobId: input.jobId };
|
return { status: 'queued', jobId: input.jobId };
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-14
@@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar
|
|||||||
|
|
||||||
const t = initTRPC.context<GameApiContext>().create();
|
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 }) => {
|
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
||||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||||
return next();
|
return next();
|
||||||
@@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
|||||||
|
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||||
export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
|
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||||
if (!ctx.auth) {
|
|
||||||
throw new TRPCError({
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||||
code: 'UNAUTHORIZED',
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||||
message: 'Unauthorized',
|
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||||
});
|
|
||||||
}
|
|
||||||
return next({
|
|
||||||
ctx: {
|
|
||||||
...ctx,
|
|
||||||
auth: ctx.auth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
|||||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||||
|
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
|
||||||
|
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
|
||||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,19 +19,30 @@ const profile: GameProfile = {
|
|||||||
class QueuedBattleSimTransport implements BattleSimTransport {
|
class QueuedBattleSimTransport implements BattleSimTransport {
|
||||||
public simulateCalls = 0;
|
public simulateCalls = 0;
|
||||||
public lastPayload: BattleSimJobPayload | null = null;
|
public lastPayload: BattleSimJobPayload | null = null;
|
||||||
|
public lastRequesterUserId: string | null = null;
|
||||||
|
private readonly owners = new Map<string, string>();
|
||||||
private readonly results = new Map<string, BattleSimResultPayload>();
|
private readonly results = new Map<string, BattleSimResultPayload>();
|
||||||
|
|
||||||
async simulate(payload: BattleSimJobPayload) {
|
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
|
||||||
this.simulateCalls += 1;
|
this.simulateCalls += 1;
|
||||||
this.lastPayload = payload;
|
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;
|
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);
|
this.results.set(jobId, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,8 +205,13 @@ const buildBattleRequest = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
|
const buildContext = (options: {
|
||||||
const db = {
|
state: WorldStateRow;
|
||||||
|
battleSim: BattleSimTransport;
|
||||||
|
userId?: string | null;
|
||||||
|
db?: Partial<DatabaseClient>;
|
||||||
|
}): GameApiContext => {
|
||||||
|
const db = options.db ?? {
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => options.state,
|
findFirst: async () => options.state,
|
||||||
},
|
},
|
||||||
@@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans
|
|||||||
},
|
},
|
||||||
profile.name
|
profile.name
|
||||||
);
|
);
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload | null =
|
||||||
version: 1,
|
options.userId === null
|
||||||
profile: profile.name,
|
? null
|
||||||
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
: {
|
||||||
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
version: 1,
|
||||||
sessionId: 'session-1',
|
profile: profile.name,
|
||||||
user: {
|
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
||||||
id: 'user-1',
|
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
||||||
username: 'tester',
|
sessionId: 'session-1',
|
||||||
displayName: 'Tester',
|
user: {
|
||||||
roles: [],
|
id: options.userId ?? 'user-1',
|
||||||
},
|
username: 'tester',
|
||||||
sanctions: {},
|
displayName: 'Tester',
|
||||||
};
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
db: db as unknown as DatabaseClient,
|
db: db as unknown as DatabaseClient,
|
||||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
@@ -255,14 +274,204 @@ describe('battle router orchestration', () => {
|
|||||||
const response = await caller.battle.simulate(buildBattleRequest());
|
const response = await caller.battle.simulate(buildBattleRequest());
|
||||||
expect(response.status).toBe('queued');
|
expect(response.status).toBe('queued');
|
||||||
expect(battleSim.simulateCalls).toBe(1);
|
expect(battleSim.simulateCalls).toBe(1);
|
||||||
|
expect(battleSim.lastRequesterUserId).toBe('user-1');
|
||||||
|
|
||||||
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
expect(queued.status).toBe('queued');
|
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 });
|
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
expect(completed.status).toBe('completed');
|
expect(completed.status).toBe('completed');
|
||||||
expect(completed.payload?.result).toBe(true);
|
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 bettingId = 990_071;
|
||||||
const concurrentBettingId = 990_072;
|
const concurrentBettingId = 990_072;
|
||||||
const generalId = 9_971;
|
const generalId = 9_971;
|
||||||
|
const otherGeneralId = 9_972;
|
||||||
const nationId = 990_071;
|
const nationId = 990_071;
|
||||||
|
const otherNationId = 990_072;
|
||||||
const userId = 'nation-betting-router-user';
|
const userId = 'nation-betting-router-user';
|
||||||
|
const otherUserId = 'nation-betting-router-other-user';
|
||||||
|
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||||
|
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
|
|||||||
sanctions: {},
|
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', () => {
|
integration('nation betting router', () => {
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
let worldStateId: number;
|
let worldStateId: number;
|
||||||
|
|
||||||
const buildContext = (requestId: string): GameApiContext => {
|
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
|
||||||
const redisClient = {
|
const redisClient = {
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
set: async () => null,
|
set: async () => null,
|
||||||
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
|
|||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
auth,
|
auth: actorAuth,
|
||||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
closeDb = () => connector.disconnect();
|
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.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId } });
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.general.deleteMany({ where: { id: generalId } });
|
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.nation.deleteMany({ where: { id: nationId } });
|
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
|
|
||||||
await db.nation.create({
|
await db.nation.createMany({
|
||||||
data: {
|
data: [
|
||||||
id: nationId,
|
{
|
||||||
name: '베팅국',
|
id: nationId,
|
||||||
color: '#123456',
|
name: '베팅국',
|
||||||
level: 2,
|
color: '#123456',
|
||||||
},
|
level: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherNationId,
|
||||||
|
name: '다른베팅국',
|
||||||
|
color: '#654321',
|
||||||
|
level: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await db.general.create({
|
await db.general.createMany({
|
||||||
data: {
|
data: [
|
||||||
id: generalId,
|
{
|
||||||
userId,
|
id: generalId,
|
||||||
name: '베팅장수',
|
userId,
|
||||||
nationId,
|
name: '베팅장수',
|
||||||
cityId: 1,
|
nationId,
|
||||||
npcState: 0,
|
cityId: 1,
|
||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
npcState: 0,
|
||||||
meta: {},
|
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({
|
const world = await db.worldState.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
|
|||||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.inheritancePoint.create({
|
await db.inheritancePoint.createMany({
|
||||||
data: { userId, key: 'previous', value: 1_000 },
|
data: [
|
||||||
|
{ userId, key: 'previous', value: 1_000 },
|
||||||
|
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
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.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId } });
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.general.deleteMany({ where: { id: generalId } });
|
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.nation.deleteMany({ where: { id: nationId } });
|
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
await db.worldState.delete({ where: { id: worldStateId } });
|
await db.worldState.delete({ where: { id: worldStateId } });
|
||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
});
|
});
|
||||||
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
||||||
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
|
expect(
|
||||||
.toMatchObject({ _sum: { amount: 600 } });
|
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
|
||||||
|
).toMatchObject({ _sum: { amount: 600 } });
|
||||||
expect(
|
expect(
|
||||||
await db.inheritancePoint.findUniqueOrThrow({
|
await db.inheritancePoint.findUniqueOrThrow({
|
||||||
where: { userId_key: { userId, key: 'previous' } },
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
})
|
})
|
||||||
).toMatchObject({ value: 250 });
|
).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_MAX_TECH_LEVEL = 12;
|
||||||
const DEFAULT_BASE_GOLD = 0;
|
const DEFAULT_BASE_GOLD = 0;
|
||||||
const DEFAULT_BASE_RICE = 2000;
|
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 DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
|
||||||
|
|
||||||
const normalizeCode = (value: string | null | undefined): string | null => {
|
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||||
@@ -132,6 +134,8 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
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(
|
maxResourceActionAmount: resolveNumber(
|
||||||
constValues,
|
constValues,
|
||||||
['maxResourceActionAmount'],
|
['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',
|
'nationOffices.spec.ts',
|
||||||
'nationGeneralSecret.spec.ts',
|
'nationGeneralSecret.spec.ts',
|
||||||
'npcPolicy.spec.ts',
|
'npcPolicy.spec.ts',
|
||||||
|
'battleSimulator.spec.ts',
|
||||||
|
'battleSimulatorRef.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
"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: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: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": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "node -e \"console.log('test not configured')\"",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Props {
|
|||||||
options: BattleSimOptions;
|
options: BattleSimOptions;
|
||||||
mode: 'attacker' | 'defender';
|
mode: 'attacker' | 'defender';
|
||||||
title: string;
|
title: string;
|
||||||
|
canImportServer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -59,7 +60,19 @@ const officerLevelOptions = [
|
|||||||
<div class="general-subtitle">No {{ general.no }}</div>
|
<div class="general-subtitle">No {{ general.no }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="general-actions">
|
<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>
|
<button class="action" type="button" @click="emit('save')">저장</button>
|
||||||
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
||||||
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
||||||
@@ -187,21 +200,11 @@ const officerLevelOptions = [
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>훈련</span>
|
<span>훈련</span>
|
||||||
<input
|
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
|
||||||
v-model.number="general.train"
|
|
||||||
type="number"
|
|
||||||
min="40"
|
|
||||||
:max="options.config.maxTrainByWar"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>사기</span>
|
<span>사기</span>
|
||||||
<input
|
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
|
||||||
v-model.number="general.atmos"
|
|
||||||
type="number"
|
|
||||||
min="40"
|
|
||||||
:max="options.config.maxAtmosByWar"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>전특</span>
|
<span>전특</span>
|
||||||
@@ -308,30 +311,15 @@ const officerLevelOptions = [
|
|||||||
<div class="form-row buff-row">
|
<div class="form-row buff-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 회피</span>
|
<span>상대 회피</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warAvoidRatioOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 필살</span>
|
<span>상대 필살</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warCriticalRatioOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 계략</span>
|
<span>상대 계략</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,6 +379,11 @@ const officerLevelOptions = [
|
|||||||
color: #f0b6b6;
|
color: #f0b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.form-block {
|
.form-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -197,7 +197,6 @@ const routes = [
|
|||||||
component: BattleSimulatorView,
|
component: BattleSimulatorView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresGeneral: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type BattleExport = {
|
|||||||
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
|
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
|
||||||
|
|
||||||
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
|
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
|
||||||
|
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
|
|||||||
const generalList = ref<GeneralListResponse | null>(null);
|
const generalList = ref<GeneralListResponse | null>(null);
|
||||||
const generalListLoading = ref(false);
|
const generalListLoading = ref(false);
|
||||||
const selectedGeneralId = ref<number | null>(null);
|
const selectedGeneralId = ref<number | null>(null);
|
||||||
|
const gameDefaults = ref<GeneralMeResponse>(null);
|
||||||
|
|
||||||
let generalIdSeed = 0;
|
let generalIdSeed = 0;
|
||||||
|
|
||||||
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
|
|||||||
try {
|
try {
|
||||||
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
|
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
|
||||||
options.value = context;
|
options.value = context;
|
||||||
|
gameDefaults.value = me;
|
||||||
year.value = context.world.currentYear;
|
year.value = context.world.currentYear;
|
||||||
month.value = context.world.currentMonth;
|
month.value = context.world.currentMonth;
|
||||||
repeatCnt.value = 1;
|
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(() => {
|
onMounted(() => {
|
||||||
void initializeDefaults();
|
void initializeDefaults();
|
||||||
});
|
});
|
||||||
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSimulating.value = true;
|
isSimulating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
if (action === 'battle') {
|
||||||
|
battleResult.value = null;
|
||||||
|
}
|
||||||
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = buildBattlePayload(action);
|
const payload = buildBattlePayload(action);
|
||||||
const response = await trpc.battle.simulate.mutate(payload);
|
const response = await trpc.battle.simulate.mutate(payload);
|
||||||
const result =
|
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) {
|
if (!result.result) {
|
||||||
error.value = result.reason || 'battle_failed';
|
error.value = result.reason || 'battle_failed';
|
||||||
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openImportModal = async (target: GeneralDraft) => {
|
const openImportModal = async (target: GeneralDraft) => {
|
||||||
|
if (!hasGameGeneral.value) {
|
||||||
|
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
importTarget.value = target;
|
importTarget.value = target;
|
||||||
importOpen.value = true;
|
importOpen.value = true;
|
||||||
if (!generalList.value) {
|
if (!generalList.value) {
|
||||||
@@ -801,47 +855,62 @@ const closeImportModal = () => {
|
|||||||
importTarget.value = null;
|
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 () => {
|
const confirmImport = async () => {
|
||||||
if (!importTarget.value || !selectedGeneralId.value) {
|
if (!importTarget.value || !selectedGeneralId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
|
await applyServerGeneral(importTarget.value, 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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
|
|||||||
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
||||||
{
|
{
|
||||||
label: '준 피해',
|
label: '준 피해',
|
||||||
value: battleResult.value.minKilled !== battleResult.value.maxKilled
|
value:
|
||||||
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
battleResult.value.minKilled !== battleResult.value.maxKilled
|
||||||
battleResult.value.maxKilled
|
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
||||||
)})`
|
battleResult.value.maxKilled
|
||||||
: formatNumber(battleResult.value.killed),
|
)})`
|
||||||
|
: formatNumber(battleResult.value.killed),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '받은 피해',
|
label: '받은 피해',
|
||||||
value: battleResult.value.minDead !== battleResult.value.maxDead
|
value:
|
||||||
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
battleResult.value.minDead !== battleResult.value.maxDead
|
||||||
battleResult.value.maxDead
|
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
||||||
)})`
|
battleResult.value.maxDead
|
||||||
: formatNumber(battleResult.value.dead),
|
)})`
|
||||||
|
: formatNumber(battleResult.value.dead),
|
||||||
},
|
},
|
||||||
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
|
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
|
||||||
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
|
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
|
||||||
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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="error" class="error">{{ error }}</div>
|
||||||
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
||||||
|
|
||||||
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
:options="options!"
|
:options="options!"
|
||||||
mode="attacker"
|
mode="attacker"
|
||||||
title="출병자 설정"
|
title="출병자 설정"
|
||||||
|
:can-import-server="hasGameGeneral"
|
||||||
@import="openImportModal(attackerGeneral!)"
|
@import="openImportModal(attackerGeneral!)"
|
||||||
@save="saveGeneral(attackerGeneral!)"
|
@save="saveGeneral(attackerGeneral!)"
|
||||||
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
||||||
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
:options="options!"
|
:options="options!"
|
||||||
mode="defender"
|
mode="defender"
|
||||||
:title="`수비자 설정 ${index + 1}`"
|
:title="`수비자 설정 ${index + 1}`"
|
||||||
|
:can-import-server="hasGameGeneral"
|
||||||
@import="openImportModal(defender)"
|
@import="openImportModal(defender)"
|
||||||
@save="saveGeneral(defender)"
|
@save="saveGeneral(defender)"
|
||||||
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
|
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
|
||||||
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="select-wrap">
|
<div v-else class="select-wrap">
|
||||||
<select v-model.number="selectedGeneralId">
|
<select v-model.number="selectedGeneralId">
|
||||||
<optgroup
|
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
|
||||||
v-for="group in generalGroups"
|
|
||||||
:key="group.nation.id"
|
|
||||||
:label="group.nation.name"
|
|
||||||
>
|
|
||||||
<option
|
<option
|
||||||
v-for="general in group.generals"
|
v-for="general in group.generals"
|
||||||
:key="general.id"
|
:key="general.id"
|
||||||
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
width: min(100%, 1000px);
|
||||||
|
margin: 0 auto;
|
||||||
padding-bottom: 30px;
|
padding-bottom: 30px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
|
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;
|
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 {
|
button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1230,6 +1355,11 @@ button {
|
|||||||
background: rgba(16, 16, 16, 0.6);
|
background: rgba(16, 16, 16, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #f5b7b1;
|
color: #f5b7b1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -1369,5 +1499,10 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.independence-notice {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
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 PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -281,6 +281,7 @@ onMounted(() => {
|
|||||||
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="join-tabs">
|
<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 === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
||||||
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,17 +465,13 @@ onMounted(() => {
|
|||||||
<section v-else class="join-grid">
|
<section v-else class="join-grid">
|
||||||
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
|
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
|
||||||
목록 새로고침
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
||||||
<div v-if="npcLoading && npcCandidates.length === 0">
|
<div v-if="npcLoading && npcCandidates.length === 0">
|
||||||
<SkeletonLines :lines="3" />
|
<SkeletonLines :lines="3" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="npcCandidates.length === 0" class="muted">
|
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
|
||||||
빙의 가능한 NPC가 없습니다.
|
|
||||||
</div>
|
|
||||||
<div v-else class="npc-list">
|
<div v-else class="npc-list">
|
||||||
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
||||||
<div class="npc-header">
|
<div class="npc-header">
|
||||||
@@ -490,9 +487,7 @@ onMounted(() => {
|
|||||||
<div>나이 {{ npc.age }}</div>
|
<div>나이 {{ npc.age }}</div>
|
||||||
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
|
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
|
||||||
빙의
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="npc-footer">
|
<div class="npc-footer">
|
||||||
@@ -542,6 +537,14 @@ onMounted(() => {
|
|||||||
font-size: 0.8rem;
|
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 {
|
.join-tabs button.active {
|
||||||
background: rgba(201, 164, 90, 0.2);
|
background: rgba(201, 164, 90, 0.2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listItems = computed(() =>
|
const listItems = computed(() =>
|
||||||
list.value
|
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
|
||||||
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
|
|
||||||
: []
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const info = computed(() => detail.value?.bettingInfo ?? null);
|
const info = computed(() => detail.value?.bettingInfo ?? null);
|
||||||
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
|
|||||||
|
|
||||||
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
||||||
|
|
||||||
const totalAmount = computed(() =>
|
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
|
||||||
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const pureAmount = computed(() =>
|
const pureAmount = computed(() =>
|
||||||
(detail.value?.bettingDetail ?? []).reduce(
|
(detail.value?.bettingDetail ?? []).reduce(
|
||||||
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const usedAmount = computed(() =>
|
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
|
||||||
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
|
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 : '요청을 처리하지 못했습니다.';
|
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseYearMonth = (yearMonth: number): [number, number] => [
|
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
|
||||||
Math.floor(yearMonth / 12),
|
|
||||||
(yearMonth % 12) + 1,
|
|
||||||
];
|
|
||||||
|
|
||||||
const readSelection = (value: string): number[] => {
|
const readSelection = (value: string): number[] => {
|
||||||
try {
|
try {
|
||||||
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
|
|||||||
.map((index) => candidates.value[index]?.title ?? '-')
|
.map((index) => candidates.value[index]?.title ?? '-')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
const isListOpen = (item: BettingListItem): boolean =>
|
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
|
||||||
!item.finished && listYearMonth.value <= item.closeYearMonth;
|
|
||||||
|
|
||||||
const isDetailOpen = computed(() =>
|
const isDetailOpen = computed(() =>
|
||||||
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
|
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';
|
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedMultiplier = (key: string, betAmount: number): string => {
|
const rewardByMatch = computed(() => {
|
||||||
if (betAmount <= 0) {
|
const selectCount = info.value?.selectCnt ?? 0;
|
||||||
return '0.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) {
|
if (!info.value?.finished) {
|
||||||
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
||||||
return (reward / betAmount).toFixed(1);
|
|
||||||
}
|
}
|
||||||
const matched = matchCount(key);
|
return rewardByMatch.value[matchCount(key)] ?? 0;
|
||||||
const matchedAmount = detailRows.value
|
};
|
||||||
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
|
|
||||||
.reduce((sum, [, value]) => sum + value, 0);
|
const rewardDivisor = (key: string, betAmount: number): number =>
|
||||||
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
|
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 () => {
|
const loadList = async () => {
|
||||||
@@ -295,7 +338,7 @@ onMounted(() => {
|
|||||||
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
||||||
<header class="legacy-top-bar">
|
<header class="legacy-top-bar">
|
||||||
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
||||||
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
|
<div></div>
|
||||||
<h1>국가 베팅장</h1>
|
<h1>국가 베팅장</h1>
|
||||||
<div></div>
|
<div></div>
|
||||||
<div></div>
|
<div></div>
|
||||||
@@ -309,32 +352,37 @@ onMounted(() => {
|
|||||||
{{ info.name }}
|
{{ info.name }}
|
||||||
<span v-if="info.finished">(종료)</span>
|
<span v-if="info.finished">(종료)</span>
|
||||||
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
||||||
({{ parseYearMonth(info.closeYearMonth)[0] }}년
|
({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
||||||
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>(베팅 마감)</span>
|
<span v-else>(베팅 마감)</span>
|
||||||
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="betting-candidates">
|
<div class="betting-candidates">
|
||||||
<button
|
<div
|
||||||
v-for="(candidate, index) in candidates"
|
v-for="(candidate, index) in candidates"
|
||||||
:key="`${info.id}-${index}`"
|
:key="`${info.id}-${index}`"
|
||||||
type="button"
|
class="betting-candidate-cell"
|
||||||
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>
|
<button
|
||||||
<span class="candidate-info">
|
type="button"
|
||||||
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
class="betting-candidate"
|
||||||
</span>
|
:class="{
|
||||||
<span class="candidate-rate">
|
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
|
||||||
선택율:
|
}"
|
||||||
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
:disabled="!isDetailOpen"
|
||||||
</span>
|
@click="toggleCandidate(index)"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
||||||
@@ -361,7 +409,7 @@ onMounted(() => {
|
|||||||
{{ selectionLabel(key) }}
|
{{ selectionLabel(key) }}
|
||||||
</div>
|
</div>
|
||||||
<div>{{ betAmount.toLocaleString('ko-KR') }}</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>{{ expectedMultiplier(key, betAmount) }}배</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,8 +430,7 @@ onMounted(() => {
|
|||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
<span v-if="item.finished">(종료)</span>
|
<span v-if="item.finished">(종료)</span>
|
||||||
<span v-else-if="isListOpen(item)">
|
<span v-else-if="isListOpen(item)">
|
||||||
({{ parseYearMonth(item.closeYearMonth)[0] }}년
|
({{ parseYearMonth(item.closeYearMonth)[0] }}년 {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
||||||
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>(베팅 마감)</span>
|
<span v-else>(베팅 마감)</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -400,12 +447,11 @@ onMounted(() => {
|
|||||||
.nation-betting-page {
|
.nation-betting-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 500px;
|
width: 500px;
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.3;
|
line-height: 1.5;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,19 +504,32 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
min-height: 22px;
|
min-height: 21px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 22px;
|
line-height: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidates {
|
.betting-candidates {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
margin-top: -3.5px;
|
||||||
padding: 4px;
|
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 {
|
.betting-candidate {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 143px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
@@ -530,7 +589,7 @@ onMounted(() => {
|
|||||||
.betting-form input {
|
.betting-form input {
|
||||||
grid-column: span 4;
|
grid-column: span 4;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 30px;
|
height: 35.5px;
|
||||||
border: 1px solid #777;
|
border: 1px solid #777;
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
color: #303030;
|
color: #303030;
|
||||||
@@ -538,10 +597,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-form button {
|
.betting-form button {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
|
height: 35.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-table {
|
.payout-table {
|
||||||
margin-top: 6px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-row {
|
.payout-row {
|
||||||
@@ -551,7 +611,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.payout-row > div {
|
.payout-row > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 2px 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-row > div:not(:first-child) {
|
.payout-row > div:not(:first-child) {
|
||||||
@@ -572,7 +632,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-item {
|
.betting-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: auto;
|
||||||
margin: 0.25em;
|
margin: 0.25em;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -595,6 +655,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-footer .legacy-nav-button {
|
.betting-footer .legacy-nav-button {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
|
height: 35.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-notice,
|
.betting-notice,
|
||||||
@@ -602,6 +663,15 @@ onMounted(() => {
|
|||||||
padding: 6px 10px;
|
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 {
|
.betting-notice.error {
|
||||||
border: 1px solid #9b4848;
|
border: 1px solid #9b4848;
|
||||||
color: #ffd0d0;
|
color: #ffd0d0;
|
||||||
@@ -617,8 +687,9 @@ onMounted(() => {
|
|||||||
width: 1000px;
|
width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidates {
|
.betting-candidate-cell {
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
|
||||||
|
width: 16.66666667%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-form {
|
.betting-form {
|
||||||
|
|||||||
@@ -817,6 +817,7 @@ export const adminRouter = router({
|
|||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
korName: string;
|
korName: string;
|
||||||
@@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
|
|
||||||
private mapProfile(
|
private mapProfile(
|
||||||
row: GatewayProfileRecord,
|
row: GatewayProfileRecord,
|
||||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
|
runtimeMap: Map<
|
||||||
|
string,
|
||||||
|
{ apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean }
|
||||||
|
>
|
||||||
): LobbyProfileStatus {
|
): LobbyProfileStatus {
|
||||||
const meta = row.meta;
|
const meta = row.meta;
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
runtime: runtimeMap.get(row.profileName) ?? {
|
runtime: runtimeMap.get(row.profileName) ?? {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
|
|||||||
export interface ProfileRuntimeState {
|
export interface ProfileRuntimeState {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +67,19 @@ export const planProfileReconcile = (
|
|||||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||||
return {
|
return {
|
||||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
|
shouldStart: !(
|
||||||
|
runtime.apiRunning &&
|
||||||
|
runtime.daemonRunning &&
|
||||||
|
runtime.battleSimRunning &&
|
||||||
|
runtime.tournamentRunning
|
||||||
|
),
|
||||||
shouldStop: false,
|
shouldStop: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
shouldStart: false,
|
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 =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
`sammo:${profileName}:${
|
||||||
|
role === 'api'
|
||||||
|
? 'game-api'
|
||||||
|
: role === 'daemon'
|
||||||
|
? 'turn-daemon'
|
||||||
|
: role === 'battle-sim'
|
||||||
|
? 'battle-sim-worker'
|
||||||
|
: 'tournament-worker'
|
||||||
|
}`;
|
||||||
|
|
||||||
const isMissingProcessError = (error: unknown): boolean =>
|
const isMissingProcessError = (error: unknown): boolean =>
|
||||||
error instanceof Error && /process or namespace not found/i.test(error.message);
|
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> };
|
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
daemon: { 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> };
|
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
} => {
|
} => {
|
||||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||||
@@ -327,6 +344,15 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: daemonCwd,
|
cwd: daemonCwd,
|
||||||
env: daemonEnv,
|
env: daemonEnv,
|
||||||
},
|
},
|
||||||
|
battleSim: {
|
||||||
|
name: battleSimName,
|
||||||
|
script: apiScript,
|
||||||
|
cwd: apiCwd,
|
||||||
|
env: {
|
||||||
|
...apiEnv,
|
||||||
|
GAME_API_ROLE: 'battle-sim-worker',
|
||||||
|
},
|
||||||
|
},
|
||||||
tournament: {
|
tournament: {
|
||||||
name: tournamentName,
|
name: tournamentName,
|
||||||
script: apiScript,
|
script: apiScript,
|
||||||
@@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
|||||||
profileNames.map((profileName) => {
|
profileNames.map((profileName) => {
|
||||||
const apiName = buildProcessName(profileName, 'api');
|
const apiName = buildProcessName(profileName, 'api');
|
||||||
const daemonName = buildProcessName(profileName, 'daemon');
|
const daemonName = buildProcessName(profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||||
return {
|
return {
|
||||||
profileName,
|
profileName,
|
||||||
apiRunning: processNames.get(apiName) ?? false,
|
apiRunning: processNames.get(apiName) ?? false,
|
||||||
daemonRunning: processNames.get(daemonName) ?? false,
|
daemonRunning: processNames.get(daemonName) ?? false,
|
||||||
|
battleSimRunning: processNames.get(battleSimName) ?? false,
|
||||||
tournamentRunning: processNames.get(tournamentName) ?? false,
|
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
try {
|
try {
|
||||||
await this.processManager.start(definitions.api);
|
await this.processManager.start(definitions.api);
|
||||||
await this.processManager.start(definitions.daemon);
|
await this.processManager.start(definitions.daemon);
|
||||||
|
await this.processManager.start(definitions.battleSim);
|
||||||
await this.processManager.start(definitions.tournament);
|
await this.processManager.start(definitions.tournament);
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return true;
|
return true;
|
||||||
@@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const name of [apiName, daemonName, tournamentName]) {
|
for (const name of [apiName, daemonName, battleSimName, tournamentName]) {
|
||||||
if (!existingNames.has(name)) {
|
if (!existingNames.has(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const createHarness = (
|
|||||||
? [
|
? [
|
||||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||||
{ name: 'sammo:che:2:turn-daemon', 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' },
|
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -138,7 +139,7 @@ const createHarness = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('GatewayOrchestrator first-class operations', () => {
|
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'));
|
const harness = createHarness(buildOperation('START'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
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'));
|
const harness = createHarness(buildOperation('STOP'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.completions).toEqual(['FAILED']);
|
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);
|
const harness = createHarness(buildOperation('STOP'), false, true);
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['FAILED']);
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -39,6 +40,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('PREOPEN', {
|
planProfileReconcile('PREOPEN', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -49,6 +51,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
battleSimRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
@@ -59,6 +62,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('STOPPED', {
|
planProfileReconcile('STOPPED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: true });
|
).toEqual({ shouldStart: false, shouldStop: true });
|
||||||
@@ -69,6 +73,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RESERVED', {
|
planProfileReconcile('RESERVED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: 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.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||||
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
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({
|
expect(definitions.tournament).toMatchObject({
|
||||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
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.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
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'));
|
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
|
|||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
apiRunning: runtimeRunning,
|
apiRunning: runtimeRunning,
|
||||||
daemonRunning: runtimeRunning,
|
daemonRunning: runtimeRunning,
|
||||||
|
battleSimRunning: runtimeRunning,
|
||||||
tournamentRunning: runtimeRunning,
|
tournamentRunning: runtimeRunning,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type AdminProfile = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
buildCommitSha?: string;
|
buildCommitSha?: string;
|
||||||
@@ -1352,7 +1353,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
상태: {{ 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' }}
|
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ type Profile = {
|
|||||||
buildWorkspace?: string;
|
buildWorkspace?: string;
|
||||||
buildError?: string;
|
buildError?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
|
runtime: {
|
||||||
|
apiRunning: boolean;
|
||||||
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
|
tournamentRunning: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
@@ -377,6 +382,14 @@ onBeforeUnmount(() => {
|
|||||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
</div>
|
</div>
|
||||||
</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="rounded bg-zinc-950 p-3">
|
||||||
<div class="text-xs text-zinc-500">Tournament worker</div>
|
<div class="text-xs text-zinc-500">Tournament worker</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한
|
|||||||
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
||||||
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
||||||
비교한다.
|
비교한다.
|
||||||
|
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
|
||||||
|
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
|
||||||
|
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
|
||||||
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
|
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
|
||||||
않아 동적 호환 판정에서 제외한다.
|
않아 동적 호환 판정에서 제외한다.
|
||||||
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
|
나머지 명령별 제약 실패·값 경계와 전체 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
|
employment plus nonexistent city IDs for spying and movement. Both engines
|
||||||
reject the requested command, execute rest without command RNG, and produce
|
reject the requested command, execute rest without command RNG, and produce
|
||||||
the same semantic state delta.
|
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
|
Requests that omit the required argument object remain unverified because the
|
||||||
reference runner did not terminate within the bounded comparison run.
|
reference runner did not terminate within the bounded comparison run.
|
||||||
This is not yet a claim that every command-specific
|
This is not yet a claim that every command-specific
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ NPC list, including mutations and recoverable API failures.
|
|||||||
betting routes, including a recoverable failed bet.
|
betting routes, including a recoverable failed bet.
|
||||||
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
|
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
|
||||||
명예의 전당 computed DOM without embedding the reference password.
|
명예의 전당 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:
|
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
|
`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
|
||||||
tournament and betting screenshots.
|
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:
|
The suite starts both applications at their public prefixes:
|
||||||
|
|
||||||
- gateway: `http://127.0.0.1:15100/gateway/`
|
- 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/` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 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 |
|
| 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 |
|
| 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 | `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 |
|
| 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;
|
initialAllowedTechLevel?: number;
|
||||||
baseGold: number;
|
baseGold: number;
|
||||||
baseRice: number;
|
baseRice: number;
|
||||||
|
generalMinimumGold?: number;
|
||||||
|
generalMinimumRice?: number;
|
||||||
maxResourceActionAmount: number;
|
maxResourceActionAmount: number;
|
||||||
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
||||||
generalActionModules?: Array<GeneralActionModule>;
|
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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||||
|
|
||||||
export interface TradeEnvironment {
|
export interface TradeEnvironment {
|
||||||
exchangeFee?: number;
|
exchangeFee?: number;
|
||||||
@@ -46,8 +47,10 @@ export class ActionDefinition<
|
|||||||
if (!parsed || !Number.isFinite(parsed.amount)) {
|
if (!parsed || !Number.isFinite(parsed.amount)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const maxAmount = this.env.maxResourceActionAmount ?? 10_000;
|
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount ?? 10_000);
|
||||||
const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount));
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { buyRice: parsed.buyRice, amount };
|
return { buyRice: parsed.buyRice, amount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
|
denyWithReason,
|
||||||
existsDestGeneral,
|
existsDestGeneral,
|
||||||
friendlyDestGeneral,
|
friendlyDestGeneral,
|
||||||
notBeNeutral,
|
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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '증여';
|
const ACTION_NAME = '증여';
|
||||||
const ACTION_KEY = 'che_증여';
|
const ACTION_KEY = 'che_증여';
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
isGold: z.boolean(),
|
isGold: z.boolean(),
|
||||||
amount: z.preprocess(
|
amount: z.number(),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
destGeneralID: z.number().int().positive(),
|
destGeneralID: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type GiftArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type GiftArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
@@ -51,10 +50,13 @@ export class ActionDefinition<
|
|||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return null;
|
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 {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
amount: Math.max(100, Math.min(parsed.amount, maxAmount)),
|
amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +64,12 @@ export class ActionDefinition<
|
|||||||
return [notBeNeutral(), occupiedCity(), suppliedCity()];
|
return [notBeNeutral(), occupiedCity(), suppliedCity()];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, args: GiftArgs): Constraint[] {
|
||||||
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
|
if (ctx.actorId === args.destGeneralID) {
|
||||||
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
|
return [denyWithReason('본인입니다')];
|
||||||
|
}
|
||||||
|
const minGold = this.env.generalMinimumGold ?? 0;
|
||||||
|
const minRice = this.env.generalMinimumRice ?? 500;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
@@ -83,8 +88,8 @@ export class ActionDefinition<
|
|||||||
throw new Error('증여 대상 장수가 없습니다.');
|
throw new Error('증여 대상 장수가 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
|
const minGold = this.env.generalMinimumGold ?? 0;
|
||||||
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
|
const minRice = this.env.generalMinimumRice ?? 500;
|
||||||
|
|
||||||
const resKey = args.isGold ? 'gold' : 'rice';
|
const resKey = args.isGold ? 'gold' : 'rice';
|
||||||
const resName = args.isGold ? '금' : '쌀';
|
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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '헌납';
|
const ACTION_NAME = '헌납';
|
||||||
const ACTION_KEY = 'che_헌납';
|
const ACTION_KEY = 'che_헌납';
|
||||||
@@ -96,12 +97,23 @@ export class ActionDefinition<
|
|||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver();
|
this.resolver = new ActionResolver();
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): DonateArgs | null {
|
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[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: DonateArgs): Constraint[] {
|
||||||
@@ -109,10 +121,12 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] {
|
||||||
|
const minGold = this.env.generalMinimumGold ?? 0;
|
||||||
|
const minRice = this.env.generalMinimumRice ?? 500;
|
||||||
if (args.isGold) {
|
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> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
@@ -128,5 +142,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { isGold: true, amount: 0 },
|
availabilityArgs: { isGold: true, amount: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
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';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const imageRoots = [
|
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||||
resolve(repositoryRoot, '../image/game'),
|
|
||||||
resolve(repositoryRoot, '../../image/game'),
|
|
||||||
];
|
|
||||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
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 geometry = await page.locator('#nation-betting-container').evaluate((container) => {
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.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 cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
|
||||||
const cardStyle = getComputedStyle(cards[0]!);
|
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 {
|
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 },
|
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: {
|
cardStyle: {
|
||||||
borderWidth: cardStyle.borderWidth,
|
borderWidth: cardStyle.borderWidth,
|
||||||
borderRadius: cardStyle.borderRadius,
|
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, height: 435 });
|
||||||
expect(geometry.container).toEqual({ x: 140, width: 1000 });
|
|
||||||
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
|
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({
|
expect(geometry.cardStyle).toEqual({
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderRadius: '7px',
|
borderRadius: '7px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
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(0).click();
|
||||||
await page.locator('.betting-candidate').nth(1).click();
|
await page.locator('.betting-candidate').nth(1).click();
|
||||||
const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => {
|
const pickedStyle = await page
|
||||||
const style = getComputedStyle(candidate);
|
.locator('.betting-candidate')
|
||||||
return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight };
|
.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)');
|
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
|
||||||
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
||||||
expect(pickedStyle.outlineWidth).toBe('1px');
|
expect(pickedStyle.outlineWidth).toBe('1px');
|
||||||
@@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
|||||||
return {
|
return {
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
|
firstX: cards[0]!.getBoundingClientRect().x,
|
||||||
firstWidth: cards[0]!.getBoundingClientRect().width,
|
firstWidth: cards[0]!.getBoundingClientRect().width,
|
||||||
fourthY: cards[3]!.getBoundingClientRect().y,
|
fourthY: cards[3]!.getBoundingClientRect().y,
|
||||||
firstY: cards[0]!.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.x).toBe(0);
|
||||||
expect(geometry.width).toBe(500);
|
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);
|
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
|
||||||
|
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
@@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
|||||||
expect(geometry.tableWidth).toBe(1000);
|
expect(geometry.tableWidth).toBe(1000);
|
||||||
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
||||||
expect(geometry.headerWidths).toEqual([
|
expect(geometry.headerWidths).toEqual([
|
||||||
104.609375,
|
104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80,
|
||||||
104.609375,
|
|
||||||
69.734375,
|
|
||||||
121.015625,
|
|
||||||
69.734375,
|
|
||||||
90.25,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
80,
|
|
||||||
80.109375,
|
80.109375,
|
||||||
]);
|
]);
|
||||||
expect(geometry.headerStyle).toEqual({
|
expect(geometry.headerStyle).toEqual({
|
||||||
@@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
|||||||
lineHeight: '18.2px',
|
lineHeight: '18.2px',
|
||||||
});
|
});
|
||||||
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
|
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();
|
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
|
||||||
await personality.hover();
|
await personality.hover();
|
||||||
await expect(personality.getByRole('tooltip')).toBeVisible();
|
await expect(personality.getByRole('tooltip')).toBeVisible();
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ const buildWorldInput = (
|
|||||||
maxGeneral: 500,
|
maxGeneral: 500,
|
||||||
baseGold: 0,
|
baseGold: 0,
|
||||||
baseRice: 2_000,
|
baseRice: 2_000,
|
||||||
|
generalMinimumGold: 0,
|
||||||
|
generalMinimumRice: 500,
|
||||||
maxResourceActionAmount: 10_000,
|
maxResourceActionAmount: 10_000,
|
||||||
maxTechLevel: 12,
|
maxTechLevel: 12,
|
||||||
maxLevel: 255,
|
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 = {
|
type GeneralConstraintCase = {
|
||||||
name: string;
|
name: string;
|
||||||
action: string;
|
action: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user