Merge branch 'main' into feature/dynasty-list-parity

# Conflicts:
#	tools/frontend-legacy-parity/playwright.config.mjs
This commit is contained in:
2026-07-26 05:41:13 +00:00
105 changed files with 13336 additions and 3227 deletions
@@ -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;
} }
} }
+22 -17
View File
@@ -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,
}); });
+2 -2
View File
@@ -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>;
} }
+1
View File
@@ -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;
} }
+42 -24
View File
@@ -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();
} }
}; };
+3
View File
@@ -30,6 +30,9 @@ export * from './auction/types.js';
export * from './auction/keys.js'; export * from './auction/keys.js';
export * from './auction/scheduler.js'; export * from './auction/scheduler.js';
export * from './auction/worker.js'; export * from './auction/worker.js';
export * from './tournament/keys.js';
export * from './tournament/store.js';
export * from './tournament/types.js';
export * from './tournament/worker.js'; export * from './tournament/worker.js';
// Types for TRPC consumer // Types for TRPC consumer
+2 -1
View File
@@ -23,13 +23,14 @@ export const resolveNationInfo = async (
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => { export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
const nation = await resolveNationInfo(db, general.nationId); const nation = await resolveNationInfo(db, general.nationId);
const picture = general.picture?.trim() || 'default.jpg';
return { return {
generalId: general.id, generalId: general.id,
generalName: general.name, generalName: general.name,
nationId: general.nationId, nationId: general.nationId,
nationName: nation.name, nationName: nation.name,
color: nation.color, color: nation.color,
icon: '', icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`,
}; };
}; };
+13 -5
View File
@@ -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 };
} }
+10 -29
View File
@@ -37,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => {
}; };
const resolveUserSettings = (meta: Record<string, unknown>) => { const resolveUserSettings = (meta: Record<string, unknown>) => {
const settings = asRecord(meta.userSettings); // The legacy general columns are persisted at the top level of General.meta.
const mysetRaw = settings.myset; // Keep reading the short-lived nested shape for installations that ran the
// initial rewrite implementation before this compatibility fix.
const nestedSettings = asRecord(meta.userSettings);
const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key];
const mysetRaw = readSetting('myset');
const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null; const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null;
return { return {
tnmt: readNumber(settings.tnmt, 1), tnmt: readNumber(readSetting('tnmt'), 1),
defence_train: readNumber(settings.defence_train, 80), defence_train: readNumber(readSetting('defence_train'), 80),
use_treatment: readNumber(settings.use_treatment, 10), use_treatment: readNumber(readSetting('use_treatment'), 10),
use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1), use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
myset, myset,
}; };
}; };
@@ -262,29 +266,6 @@ export const generalRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
} }
const metaRecord = asRecord(general.meta);
const prevSettings = asRecord(metaRecord.userSettings);
const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset)
? prevSettings.myset
: null;
const nextSettings = {
...prevSettings,
...input,
} as Record<string, unknown>;
if (typeof prevMyset === 'number') {
nextSettings.myset = Math.max(0, prevMyset - 1);
}
await ctx.db.general.update({
where: { id: general.id },
data: {
meta: {
...metaRecord,
userSettings: nextSettings,
},
} as any,
});
return { ok: true }; return { ok: true };
}), }),
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => { dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
+87 -24
View File
@@ -3,7 +3,14 @@ import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common'; import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic'; import {
ItemLoader,
isItemKey,
loadWarTraitModules,
WarTraitLoader,
WAR_TRAIT_KEYS,
isWarTraitKey,
} from '@sammo-ts/logic';
import type { InheritBuffType } from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic';
import { import {
appendInheritanceLog, appendInheritanceLog,
@@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio', 'warAvoidRatio',
'warCriticalRatio', 'warCriticalRatio',
'warMagicTrialProb', 'warMagicTrialProb',
'success', 'domesticSuccessProb',
'fail', 'domesticFailProb',
'warAvoidRatioOppose', 'warAvoidRatioOppose',
'warCriticalRatioOppose', 'warCriticalRatioOppose',
'warMagicTrialProbOppose', 'warMagicTrialProbOppose',
@@ -34,8 +41,8 @@ const BUFF_LABELS: Record<InheritBuffType, string> = {
warAvoidRatio: '회피 확률 증가', warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가', warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가', warMagicTrialProb: '전투계략 시도 확률 증가',
success: '내정 성공률 증가', domesticSuccessProb: '내정 성공률 증가',
fail: '내정 실패율 감소', domesticFailProb: '내정 실패율 감소',
warAvoidRatioOppose: '상대 회피 확률 감소', warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소', warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
@@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff); const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
};
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
for (const entries of Object.values(asRecord(configuredItems))) {
for (const [key, amount] of Object.entries(asRecord(entries))) {
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
enabledKeys.push(key);
}
}
}
const loader = new ItemLoader();
const items = await Promise.all(
[...new Set(enabledKeys)].map(async (key) => {
const item = await loader.load(key);
return {
key,
name: item.name,
rawName: item.rawName,
info: item.info ?? '',
};
})
);
return items.sort((left, right) => left.name.localeCompare(right.name, 'ko'));
};
const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => { const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => {
const worldState = await ctx.db.worldState.findFirst(); const worldState = await ctx.db.worldState.findFirst();
if (!worldState || typeof worldState !== 'object') { if (!worldState || typeof worldState !== 'object') {
@@ -199,6 +237,9 @@ export const inheritRouter = router({
special2Code: true, special2Code: true,
meta: true, meta: true,
turnTime: true, turnTime: true,
leadership: true,
strength: true,
intel: true,
}, },
}); });
@@ -219,7 +260,7 @@ export const inheritRouter = router({
const inheritConst = resolveInheritConstants(worldState); const inheritConst = resolveInheritConstants(worldState);
const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff); const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff);
const buffLevels = BUFF_KEYS.reduce<Record<string, number>>((acc, key) => { const buffLevels = BUFF_KEYS.reduce<Record<string, number>>((acc, key) => {
acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0))); acc[key] = readBuffLevel(buffState, key);
return acc; return acc;
}, {}); }, {});
@@ -240,11 +281,14 @@ export const inheritRouter = router({
info: trait.info ?? '', info: trait.info ?? '',
})); }));
const others = await ctx.db.general.findMany({ const [others, availableUnique] = await Promise.all([
where: { id: { not: general.id }, userId: { not: null } }, ctx.db.general.findMany({
select: { id: true, name: true }, where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } },
orderBy: { id: 'asc' }, select: { id: true, name: true },
}); orderBy: { id: 'asc' },
}),
loadAvailableUniqueItems(worldState),
]);
return { return {
items, items,
@@ -260,10 +304,16 @@ export const inheritRouter = router({
resetTurnTime: resetTurnLevel, resetTurnTime: resetTurnLevel,
}, },
availableSpecialWar: warSpecials, availableSpecialWar: warSpecials,
availableUnique,
availableTargetGenerals: others, availableTargetGenerals: others,
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))), turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
isUnited, isUnited,
currentSpecialWar: general.special2Code ?? 'None', currentSpecialWar: general.special2Code ?? 'None',
currentStat: {
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
},
}; };
}), }),
getLogs: authedProcedure getLogs: authedProcedure
@@ -285,7 +335,7 @@ export const inheritRouter = router({
}, },
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
take: 30, take: 30,
select: { id: true, year: true, month: true, text: true }, select: { id: true, year: true, month: true, text: true, createdAt: true },
}); });
return logs; return logs;
}), }),
@@ -318,7 +368,7 @@ export const inheritRouter = router({
} }
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff); const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0))); const prevLevel = readBuffLevel(buff, input.type);
if (input.level === prevLevel) { if (input.level === prevLevel) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
} }
@@ -417,7 +467,12 @@ export const inheritRouter = router({
}, },
}); });
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint); await setInheritancePoint(
ctx.db,
userId,
'previous',
currentPoint - inheritConst.inheritSpecificSpecialPoint
);
await appendInheritanceLog( await appendInheritanceLog(
ctx.db, ctx.db,
userId, userId,
@@ -460,7 +515,8 @@ export const inheritRouter = router({
} }
const meta = asRecord(general.meta); const meta = asRecord(general.meta);
const prevList = parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; const prevList =
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
prevList.push(general.special2Code); prevList.push(general.special2Code);
await patchGeneral(ctx, general.id, { await patchGeneral(ctx, general.id, {
@@ -473,7 +529,13 @@ export const inheritRouter = router({
}); });
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`); await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${cost} 포인트로 전투 특기 초기화`
);
return { ok: true }; return { ok: true };
}), }),
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => { resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
@@ -624,9 +686,7 @@ export const inheritRouter = router({
const finalBonus = const finalBonus =
bonusSum === 0 bonusSum === 0
? buildRandomBonus( ? buildRandomBonus(
new LiteHashDRBG( new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`
),
[input.leadership, input.strength, input.intel] [input.leadership, input.strength, input.intel]
) )
: (bonus as [number, number, number]); : (bonus as [number, number, number]);
@@ -674,9 +734,7 @@ export const inheritRouter = router({
if (seasonValue !== null) { if (seasonValue !== null) {
const userState = await readUserStateMeta(ctx.db, userId); const userState = await readUserStateMeta(ctx.db, userId);
const resetSeasons = readResetSeasons(userState); const resetSeasons = readResetSeasons(userState);
const nextSeasons = resetSeasons.includes(seasonValue) const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
? resetSeasons
: [...resetSeasons, seasonValue];
await writeUserStateMeta(ctx.db, userId, { await writeUserStateMeta(ctx.db, userId, {
...userState, ...userState,
last_stat_reset: nextSeasons, last_stat_reset: nextSeasons,
@@ -709,7 +767,10 @@ export const inheritRouter = router({
} }
const meta = asRecord(general.meta); const meta = asRecord(general.meta);
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) { if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' }); throw new TRPCError({
code: 'BAD_REQUEST',
message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
});
} }
await patchGeneral(ctx, general.id, { await patchGeneral(ctx, general.id, {
@@ -803,7 +864,9 @@ export const inheritRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
} }
const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId; const rawOwnerName = asRecord(target.meta).ownerName;
const ownerName =
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint); await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
await appendInheritanceLog( await appendInheritanceLog(
+178 -16
View File
@@ -1,5 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, router } from '../../trpc.js';
import { import {
@@ -26,6 +28,75 @@ import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
const redactDiplomacyMessages = (messages: MessageView[], permission: number): MessageView[] => {
if (permission >= 3) {
return messages;
}
return messages.map((message) => {
if (!message.dest || message.dest.nationId === 0) {
return message;
}
return {
...message,
text: '(외교 메시지입니다)',
option: {
...(message.option ?? {}),
invalid: true,
},
};
});
};
const isFutureDate = (value: string | undefined, now = Date.now()): boolean => {
if (!value) {
return false;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) && parsed > now;
};
const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => {
if (
isFutureDate(sanctions.mutedUntil) ||
isFutureDate(sanctions.suspendedUntil) ||
isFutureDate(sanctions.bannedUntil)
) {
return true;
}
for (const profileName of profileNames) {
const restriction = sanctions.serverRestrictions?.[profileName];
if (!restriction) {
continue;
}
if (restriction.until && !isFutureDate(restriction.until)) {
continue;
}
if (restriction.blockedFeatures?.includes('messages')) {
return true;
}
}
return false;
};
const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => {
const value = asRecord(penalty)[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const hasPenalty = (penalty: unknown, key: string): boolean => {
const value = asRecord(penalty)[key];
return value === true || value === 1 || value === '1';
};
export const messagesRouter = router({ export const messagesRouter = router({
getRecent: authedProcedure getRecent: authedProcedure
.input( .input(
@@ -85,11 +156,12 @@ export const messagesRouter = router({
: null, : null,
]); ]);
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = { const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages, private: privateMessages,
public: publicMessages, public: publicMessages,
national: nationalMessages, national: nationalMessages,
diplomacy: diplomacyMessages, diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
}; };
let nextSequence = sequence; let nextSequence = sequence;
@@ -128,10 +200,8 @@ export const messagesRouter = router({
sequence: nextSequence, sequence: nextSequence,
nationId: nationId, nationId: nationId,
generalName: general.name, generalName: general.name,
canRespondDiplomacy: permission,
general.officerLevel > 4 && canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
nation !== null &&
resolveNationPermission(general, nation.meta, false) >= 4,
latestRead: { latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0, diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0, private: readState?.latestPrivateMessage ?? 0,
@@ -178,6 +248,7 @@ export const messagesRouter = router({
]; ];
return { return {
nation: nationList.map((nation) => ({ nation: nationList.map((nation) => ({
nationId: nation.id,
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
name: nation.name, name: nation.name,
color: nation.color, color: nation.color,
@@ -234,14 +305,24 @@ export const messagesRouter = router({
if (message.payload.src.generalId !== general.id) { if (message.payload.src.generalId !== general.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' }); throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
} }
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) { if (message.msgType === 'diplomacy' && message.payload.option?.action) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '시스템 외교 메시지는 삭제할 수 없습니다.',
});
}
if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
} }
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) { if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
} }
const receiverMessageId = message.payload.option?.receiverMessageID; const receiverMessageId = message.payload.option?.receiverMessageID;
const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])]; const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
const ids = [
message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
];
await invalidateMessages(ctx.db, ids); await invalidateMessages(ctx.db, ids);
return { ok: true, deletedIds: ids }; return { ok: true, deletedIds: ids };
}), }),
@@ -291,6 +372,14 @@ export const messagesRouter = router({
const general = await getOwnedGeneral(ctx, input.generalId); const general = await getOwnedGeneral(ctx, input.generalId);
const nationId = general.nationId; const nationId = general.nationId;
const nation =
nationId > 0
? await ctx.db.nation.findUnique({
where: { id: nationId },
select: { meta: true },
})
: null;
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const mailboxes = { const mailboxes = {
private: general.id, private: general.id,
public: MESSAGE_MAILBOX_PUBLIC, public: MESSAGE_MAILBOX_PUBLIC,
@@ -312,7 +401,8 @@ export const messagesRouter = router({
toSeq: input.to, toSeq: input.to,
limit: 15, limit: 15,
}); });
messageBuckets[input.type] = messages; messageBuckets[input.type] =
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
return { return {
result: true, result: true,
@@ -320,6 +410,7 @@ export const messagesRouter = router({
sequence: 0, sequence: 0,
nationId, nationId,
generalName: general.name, generalName: general.name,
permission,
...messageBuckets, ...messageBuckets,
}; };
}), }),
@@ -333,6 +424,12 @@ export const messagesRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId); const general = await getOwnedGeneral(ctx, input.generalId);
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
});
}
const src = await buildTargetFromGeneral(ctx.db, general); const src = await buildTargetFromGeneral(ctx.db, general);
const now = new Date(); const now = new Date();
@@ -340,28 +437,93 @@ export const messagesRouter = router({
let msgType: MessageType; let msgType: MessageType;
let dest = src; let dest = src;
let receiverMailbox = input.mailbox;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) { if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
msgType = 'public'; if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
if (destNationId <= 0) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'FORBIDDEN',
message: 'Invalid nation mailbox.', message: '공개 메세지를 보낼 수 없습니다.',
}); });
} }
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const sourceNation =
general.nationId > 0
? await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
const permission =
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
const nationInfo = await resolveNationInfo(ctx.db, destNationId); const nationInfo = await resolveNationInfo(ctx.db, destNationId);
if (destNationId > 0) {
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
if (!destNation) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '존재하지 않는 국가입니다.',
});
}
}
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color); dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
msgType = destNationId === general.nationId ? 'national' : 'diplomacy'; msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
} else if (input.mailbox > 0) { } else if (input.mailbox > 0) {
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '개인 메세지를 보낼 수 없습니다.',
});
}
const intervalSeconds = Math.max(
0,
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
);
if (intervalSeconds > 0) {
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
const acquired = await ctx.redis.set(rateLimitKey, '1', {
NX: true,
PX: intervalSeconds * 1000,
});
if (acquired === null) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
});
}
}
const destGeneral = await ctx.db.general.findUnique({ const destGeneral = await ctx.db.general.findUnique({
where: { id: input.mailbox }, where: { id: input.mailbox },
}); });
if (!destGeneral) { if (!destGeneral) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Destination general not found.', message: '존재하지 않는 유저입니다.',
});
}
const [sourceNation, destNation] = await Promise.all([
general.nationId > 0
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
: null,
destGeneral.nationId > 0
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
: null,
]);
const sourcePermission =
sourceNation && general.nationId > 0
? resolveNationPermission(general, sourceNation.meta, false)
: -1;
const destPermission =
destNation && destGeneral.nationId > 0
? resolveNationPermission(destGeneral, destNation.meta, false)
: -1;
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
}); });
} }
dest = await buildTargetFromGeneral(ctx.db, destGeneral); dest = await buildTargetFromGeneral(ctx.db, destGeneral);
@@ -394,7 +556,7 @@ export const messagesRouter = router({
await publishRealtimeEvent(ctx.redis, ctx.profile.name, { await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
type: 'messageCreated', type: 'messageCreated',
at: now.toISOString(), at: now.toISOString(),
mailbox: input.mailbox, mailbox: receiverMailbox,
msgType, msgType,
messageId: result.receiverId, messageId: result.receiverId,
senderId: general.id, senderId: general.id,
@@ -2,7 +2,21 @@ import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.js'; import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, loadTraitNames, mapGeneralList, resolveChiefStatMin } from '../shared.js'; import {
assertNationAccess,
loadTraitNames,
mapGeneralList,
resolveChiefStatMin,
resolveNationPermission,
} from '../shared.js';
const experienceLevel = (experience: number): number =>
Math.max(
0,
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
);
const dedicationLevel = (dedication: number): number =>
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10)));
export const getGeneralList = authedProcedure.query(async ({ ctx }) => { export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
@@ -62,7 +76,38 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name])); const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name]));
const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap); const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap);
const accessRows = generalRows.length
? await ctx.db.generalAccessLog.findMany({
where: { generalId: { in: generalRows.map((entry) => entry.id) } },
select: { generalId: true, refreshScoreTotal: true },
})
: [];
const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal]));
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode); const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
const permission = resolveNationPermission(general, nation.meta, true);
const visibleList = list.map((entry) => {
const { permission: _targetPermission, ...safeEntry } = entry;
if (permission >= 1) {
return {
...safeEntry,
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
experienceLevel: experienceLevel(entry.experience),
dedicationLevel: dedicationLevel(entry.dedication),
};
}
const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry;
return {
...visible,
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
officerLevel: entry.officerLevel >= 5 ? entry.officerLevel : Math.min(1, entry.officerLevel),
cityName: null,
troopName: null,
officerCity: 0,
officerCityName: null,
experienceLevel: experienceLevel(entry.experience),
dedicationLevel: dedicationLevel(entry.dedication),
};
});
return { return {
nation: { nation: {
@@ -79,6 +124,7 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
capitalCityId: nation.capitalCityId ?? 0, capitalCityId: nation.capitalCityId ?? 0,
}, },
chiefStatMin: resolveChiefStatMin(worldState), chiefStatMin: resolveChiefStatMin(worldState),
generals: list, viewer: { generalId: general.id, permission },
generals: visibleList,
}; };
}); });
@@ -0,0 +1,141 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../shared.js';
const readNumber = (record: Record<string, unknown>, keys: string[], fallback = 0): number => {
for (const key of keys) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return fallback;
};
const woundedStat = (value: number, injury: number): number =>
injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value;
const experienceLevel = (experience: number): number =>
Math.max(
0,
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
);
const leadershipBonus = (officerLevel: number, nationLevel: number): number =>
officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0;
const defenceTrainText = (value: number): string =>
value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△';
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { id: true, name: true, color: true, level: true, meta: true },
});
if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
const permission = resolveNationPermission(me, nation.meta, true);
if (permission < 1) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.',
});
}
const [cities, troops, generalRows] = await Promise.all([
ctx.db.city.findMany({ select: { id: true, name: true } }),
ctx.db.troop.findMany({
where: { nationId: me.nationId },
select: { troopLeaderId: true, name: true },
}),
ctx.db.general.findMany({
where: { nationId: me.nationId },
orderBy: [{ turnTime: 'asc' }, { id: 'asc' }],
}),
]);
const generalIds = generalRows.map((general) => general.id);
const turns = generalIds.length
? await ctx.db.generalTurn.findMany({
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
})
: [];
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
const troopNames = new Map(troops.map((troop) => [troop.troopLeaderId, troop.name]));
const turnMap = new Map<number, string[]>();
for (const turn of turns) {
const list = turnMap.get(turn.generalId) ?? [];
list[turn.turnIdx] = turn.actionCode;
turnMap.set(turn.generalId, list);
}
const generals = generalRows.map((general) => {
const meta = asRecord(general.meta);
const defenceTrain = readNumber(meta, ['defenceTrain', 'defence_train'], 80);
return {
id: general.id,
name: general.name,
npcState: general.npcState,
injury: general.injury,
stats: {
leadership: woundedStat(general.leadership, general.injury),
strength: woundedStat(general.strength, general.injury),
intelligence: woundedStat(general.intel, general.injury),
},
leadershipBonus: leadershipBonus(general.officerLevel, nation.level),
experienceLevel: experienceLevel(general.experience),
troopId: general.troopId,
troopName: troopNames.get(general.troopId) ?? null,
gold: general.gold,
rice: general.rice,
cityId: general.cityId,
cityName: cityNames.get(general.cityId) ?? null,
defenceTrain,
defenceTrainText: defenceTrainText(defenceTrain),
crewTypeId: general.crewTypeId,
crew: general.crew,
train: general.train,
atmos: general.atmos,
killTurn: readNumber(meta, ['killturn', 'killTurn']),
turnTime: general.turnTime.toISOString(),
reservedCommands: general.npcState < 2 ? (turnMap.get(general.id) ?? []) : [],
};
});
const counted = generals.filter((general) => general.npcState !== 5);
const summary = counted.reduce(
(result, general) => {
result.gold += general.gold;
result.rice += general.rice;
result.crew += general.crew;
if (general.crew > 0) {
for (const threshold of [90, 80, 60] as const) {
if (general.train >= threshold && general.atmos >= threshold) {
result.readiness[threshold].crew += general.crew;
result.readiness[threshold].generals += 1;
}
}
}
return result;
},
{
gold: 0,
rice: 0,
crew: 0,
readiness: {
90: { crew: 0, generals: 0 },
80: { crew: 0, generals: 0 },
60: { crew: 0, generals: 0 },
},
}
);
return {
nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level },
viewer: { generalId: me.id, permission },
summary: {
...summary,
generalCount: counted.length,
averageGold: counted.length ? summary.gold / counted.length : 0,
averageRice: counted.length ? summary.rice / counted.length : 0,
},
generals,
};
});
+2
View File
@@ -5,6 +5,7 @@ import { getBattleCenter } from './endpoints/getBattleCenter.js';
import { getChiefCenter } from './endpoints/getChiefCenter.js'; import { getChiefCenter } from './endpoints/getChiefCenter.js';
import { getCityOverview } from './endpoints/getCityOverview.js'; import { getCityOverview } from './endpoints/getCityOverview.js';
import { getGeneralList } from './endpoints/getGeneralList.js'; import { getGeneralList } from './endpoints/getGeneralList.js';
import { getSecretGeneralList } from './endpoints/getSecretGeneralList.js';
import { getGeneralLog } from './endpoints/getGeneralLog.js'; import { getGeneralLog } from './endpoints/getGeneralLog.js';
import { getNationInfo } from './endpoints/getNationInfo.js'; import { getNationInfo } from './endpoints/getNationInfo.js';
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js'; import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
@@ -21,6 +22,7 @@ import { setSecretLimit } from './endpoints/setSecretLimit.js';
export const nationRouter = router({ export const nationRouter = router({
getNationInfo, getNationInfo,
getGeneralList, getGeneralList,
getSecretGeneralList,
getCityOverview, getCityOverview,
getPersonnelInfo, getPersonnelInfo,
getStratFinan, getStratFinan,
+255 -265
View File
@@ -1,5 +1,3 @@
import path from 'node:path';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
@@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const;
type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number]; type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number];
type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number]; type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number];
const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset');
const readNumber = (value: unknown, fallback = 0): number => { const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return value; return value;
@@ -353,8 +349,8 @@ const buildZeroPolicy = async (
} }
): Promise<NationPolicy> => { ): Promise<NationPolicy> => {
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options; const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT }); const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId); const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
const techCost = getTechCost(nationTech); const techCost = getTechCost(nationTech);
const next = clonePolicy(policy); const next = clonePolicy(policy);
@@ -364,7 +360,7 @@ const buildZeroPolicy = async (
if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) { if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) {
const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0; const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0;
const baseRice = statNpcMax; const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0;
if (next.reqNPCWarGold === 0) { if (next.reqNPCWarGold === 0) {
next.reqNPCWarGold = roundTo(baseGold * 4, -2); next.reqNPCWarGold = roundTo(baseGold * 4, -2);
} }
@@ -375,7 +371,7 @@ const buildZeroPolicy = async (
if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) { if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) {
const baseGold = crewType ? crewType.cost * techCost * statMax : 0; const baseGold = crewType ? crewType.cost * techCost * statMax : 0;
const baseRice = statMax; const baseRice = crewType ? crewType.rice * techCost * statMax : 0;
if (next.reqHumanWarUrgentGold === 0) { if (next.reqHumanWarUrgentGold === 0) {
next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2); next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
} }
@@ -415,8 +411,6 @@ const resolveSetterInfo = (policy: Record<string, unknown>, kind: 'value' | 'pri
}; };
}; };
const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority));
const validateGeneralPriority = (priority: string[]): string | null => { const validateGeneralPriority = (priority: string[]): string | null => {
const orderRequired: Array<[string, string]> = [['출병', '일반내정']]; const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
const mustHave = new Set(['출병', '일반내정']); const mustHave = new Set(['출병', '일반내정']);
@@ -461,6 +455,7 @@ export const npcRouter = router({
id: true, id: true,
name: true, name: true,
level: true, level: true,
tech: true,
meta: true, meta: true,
}, },
}), }),
@@ -508,9 +503,9 @@ export const npcRouter = router({
const stat = resolveScenarioStat(config); const stat = resolveScenarioStat(config);
const env = resolveCommandEnv(config); const env = resolveCommandEnv(config);
const unitSetName = resolveUnitSetName(config, 'che'); const unitSetName = resolveUnitSetName(config, 'che');
const nationTech = readNumber(asRecord(nationMeta).tech, 0); const nationTech = readNumber(nation.tech, 0);
const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, { const zeroPolicy = await buildZeroPolicy(DEFAULT_NATION_POLICY, {
statMax: stat.max, statMax: stat.max,
statNpcMax: stat.npcMax, statNpcMax: stat.npcMax,
nationTech, nationTech,
@@ -542,277 +537,272 @@ export const npcRouter = router({
permissionLevel, permissionLevel,
}; };
}), }),
setNationPolicy: authedProcedure setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
.input(z.record(z.string(), z.unknown())) const general = await getMyGeneral(ctx);
.mutation(async ({ ctx, input }) => { if (general.nationId <= 0) {
const general = await getMyGeneral(ctx); throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
if (general.nationId <= 0) { }
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
const nation = await ctx.db.nation.findUnique({ const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId }, where: { id: general.nationId },
select: { id: true, meta: true }, select: { id: true, meta: true },
}); });
if (!nation) { if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
} }
const permissionLevel = resolveSecretPermission( const permissionLevel = resolveSecretPermission(
{ {
nationId: general.nationId, nationId: general.nationId,
officerLevel: general.officerLevel, officerLevel: general.officerLevel,
meta: general.meta, meta: general.meta,
penalty: general.penalty, penalty: general.penalty,
}, },
nation.meta nation.meta
); );
if (permissionLevel < 3) { if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
} }
const keys = Object.keys(input); const keys = Object.keys(input);
for (const key of keys) { for (const key of keys) {
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
}
const troopRows = await ctx.db.troop.findMany({
where: { nationId: general.nationId },
select: { troopLeaderId: true },
});
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
const citySet = new Set(cityRows.map((row) => row.id));
const assigned = new Set<number>();
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
for (const key of INTEGER_POLICY_KEYS) {
if (!(key in input)) {
continue;
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
}
nextValues[key] = Math.max(0, value);
}
for (const key of FLOAT_POLICY_KEYS) {
if (!(key in input)) {
continue;
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
}
nextValues[key] = value;
}
if ('CombatForce' in input) {
const rawCombat = input.CombatForce;
if (!isRecord(rawCombat)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
}
const combatForce: Record<number, [number, number]> = {};
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
const leaderId = Number(rawKey);
if (!Number.isFinite(leaderId)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
}
if (!troopSet.has(leaderId)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
}
if (assigned.has(leaderId)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
});
}
if (!Array.isArray(rawValue) || rawValue.length < 2) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${leaderId}의 입력양식이 올바르지 않습니다.`,
});
}
const fromCity = Number(rawValue[0]);
const toCity = Number(rawValue[1]);
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
});
}
combatForce[leaderId] = [fromCity, toCity];
assigned.add(leaderId);
}
nextValues.CombatForce = combatForce;
}
for (const key of ['SupportForce', 'DevelopForce'] as const) {
if (!(key in input)) {
continue;
}
const rawList = input[key];
if (!Array.isArray(rawList)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
const list: number[] = [];
for (const rawValue of rawList) {
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
} }
} if (!troopSet.has(rawValue)) {
throw new TRPCError({
const troopRows = await ctx.db.troop.findMany({ code: 'BAD_REQUEST',
where: { nationId: general.nationId }, message: `${rawValue}는 국가의 부대가 아닙니다.`,
select: { troopLeaderId: true }, });
});
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
const citySet = new Set(cityRows.map((row) => row.id));
const assigned = new Set<number>();
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
for (const key of INTEGER_POLICY_KEYS) {
if (!(key in input)) {
continue;
} }
const value = input[key]; if (assigned.has(rawValue)) {
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { throw new TRPCError({
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); code: 'BAD_REQUEST',
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
});
} }
nextValues[key] = Math.max(0, value); assigned.add(rawValue);
list.push(rawValue);
} }
if (key === 'SupportForce') {
for (const key of FLOAT_POLICY_KEYS) { nextValues.SupportForce = list;
if (!(key in input)) { } else {
continue; nextValues.DevelopForce = list;
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
}
nextValues[key] = Math.max(0, value);
} }
}
if ('CombatForce' in input) { const nextPolicyRoot = {
const rawCombat = input.CombatForce; ...policyRoot,
if (!isRecord(rawCombat)) { values: nextValues,
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); valueSetter: general.name,
} valueSetTime: new Date().toISOString(),
const combatForce: Record<number, [number, number]> = {}; };
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
const leaderId = Number(rawKey); await updateNationMeta(
if (!Number.isFinite(leaderId)) { ctx,
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); nation.id,
} {
if (!troopSet.has(leaderId)) { npc_nation_policy: nextPolicyRoot,
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); },
} nationMeta
if (assigned.has(leaderId)) { );
throw new TRPCError({
code: 'BAD_REQUEST', return { ok: true };
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, }),
}); setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
} const general = await getMyGeneral(ctx);
if (!Array.isArray(rawValue) || rawValue.length < 2) { if (general.nationId <= 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` }); throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
} }
const fromCity = Number(rawValue[0]);
const toCity = Number(rawValue[1]); const nation = await ctx.db.nation.findUnique({
if (!citySet.has(fromCity) || !citySet.has(toCity)) { where: { id: general.nationId },
throw new TRPCError({ select: { id: true, meta: true },
code: 'BAD_REQUEST', });
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, if (!nation) {
}); throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
} }
combatForce[leaderId] = [fromCity, toCity];
assigned.add(leaderId); const permissionLevel = resolveSecretPermission(
} {
nextValues.CombatForce = combatForce; nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
for (const item of input) {
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
} }
}
for (const key of ['SupportForce', 'DevelopForce'] as const) { const nationMeta = asRecord(nation.meta);
if (!(key in input)) { const policyRoot = asRecord(nationMeta.npc_nation_policy);
continue; const nextPolicyRoot = {
} ...policyRoot,
const rawList = input[key]; priority: input,
if (!Array.isArray(rawList)) { prioritySetter: general.name,
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); prioritySetTime: new Date().toISOString(),
} };
const list: number[] = [];
for (const rawValue of rawList) {
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
if (!troopSet.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${rawValue}는 국가의 부대가 아닙니다.`,
});
}
if (assigned.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
});
}
assigned.add(rawValue);
list.push(rawValue);
}
if (key === 'SupportForce') {
nextValues.SupportForce = list;
} else {
nextValues.DevelopForce = list;
}
}
const nextPolicyRoot = { await updateNationMeta(
...policyRoot, ctx,
values: nextValues, nation.id,
valueSetter: general.name, {
valueSetTime: new Date().toISOString(), npc_nation_policy: nextPolicyRoot,
}; },
nationMeta
);
await updateNationMeta( return { ok: true };
ctx, }),
nation.id, setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
{ const general = await getMyGeneral(ctx);
npc_nation_policy: nextPolicyRoot, if (general.nationId <= 0) {
}, throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
nationMeta }
);
return { ok: true }; const nation = await ctx.db.nation.findUnique({
}), where: { id: general.nationId },
setNationPriority: authedProcedure select: { id: true, meta: true },
.input(z.array(z.string())) });
.mutation(async ({ ctx, input }) => { if (!nation) {
const general = await getMyGeneral(ctx); throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
if (general.nationId <= 0) { }
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
const nation = await ctx.db.nation.findUnique({ const permissionLevel = resolveSecretPermission(
where: { id: general.nationId }, {
select: { id: true, meta: true }, nationId: general.nationId,
}); officerLevel: general.officerLevel,
if (!nation) { meta: general.meta,
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); penalty: general.penalty,
} },
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const permissionLevel = resolveSecretPermission( const validationError = validateGeneralPriority(input);
{ if (validationError) {
nationId: general.nationId, throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
officerLevel: general.officerLevel, }
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const unique = ensureUniquePriority(input); const nationMeta = asRecord(nation.meta);
for (const item of unique) { const policyRoot = asRecord(nationMeta.npc_general_policy);
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { const nextPolicyRoot = {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); ...policyRoot,
} priority: input,
} prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
const nationMeta = asRecord(nation.meta); await updateNationMeta(
const policyRoot = asRecord(nationMeta.npc_nation_policy); ctx,
const nextPolicyRoot = { nation.id,
...policyRoot, {
priority: unique, npc_general_policy: nextPolicyRoot,
prioritySetter: general.name, },
prioritySetTime: new Date().toISOString(), nationMeta
}; );
await updateNationMeta( return { ok: true };
ctx, }),
nation.id,
{
npc_nation_policy: nextPolicyRoot,
},
nationMeta
);
return { ok: true };
}),
setGeneralPriority: authedProcedure
.input(z.array(z.string()))
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const unique = ensureUniquePriority(input);
const validationError = validateGeneralPriority(unique);
if (validationError) {
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
}
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_general_policy);
const nextPolicyRoot = {
...policyRoot,
priority: unique,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
await updateNationMeta(
ctx,
nation.id,
{
npc_general_policy: nextPolicyRoot,
},
nationMeta
);
return { ok: true };
}),
}); });
+117
View File
@@ -42,6 +42,14 @@ type NationCountRow = {
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
type TrafficHistoryItem = {
year: number;
month: number;
refresh: number;
online: number;
date: string;
};
const PUBLIC_CACHE_TTL_SECONDS = 600; const PUBLIC_CACHE_TTL_SECONDS = 600;
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
@@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
return typeof value === 'number' && Number.isFinite(value) ? value : 0; return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}; };
const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => {
if (!Array.isArray(value)) {
return [];
}
const result: TrafficHistoryItem[] = [];
for (const item of value) {
const row = asRecord(item);
const year = readFiniteMetaNumber(row, 'year');
const month = readFiniteMetaNumber(row, 'month');
const refresh = readFiniteMetaNumber(row, 'refresh');
const online = readFiniteMetaNumber(row, 'online');
const date = typeof row.date === 'string' ? row.date : '';
if (year > 0 && month > 0 && date) {
result.push({ year, month, refresh, online, date });
}
}
return result;
};
const compareString = (left: string, right: string): number => { const compareString = (left: string, right: string): number => {
if (left === right) { if (left === right) {
return 0; return 0;
@@ -222,6 +250,95 @@ export const publicRouter = router({
getNationList: procedure.query(async ({ ctx }) => { getNationList: procedure.query(async ({ ctx }) => {
return loadCachedNationList(ctx); return loadCachedNationList(ctx);
}), }),
getTraffic: procedure.query(async ({ ctx }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const meta = asRecord(worldState.meta);
const rawOnlineSince = meta.lastTurnTime ?? meta.turntime;
const parsedOnlineSince =
typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date
? new Date(rawOnlineSince)
: null;
const onlineSince =
parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime())
? parsedOnlineSince
: new Date(Date.now() - worldState.tickSeconds * 1_000);
const [accessTotal, currentOnline, topAccess] = await Promise.all([
ctx.db.generalAccessLog.aggregate({
_sum: {
refresh: true,
refreshScoreTotal: true,
},
}),
ctx.db.generalAccessLog.count({
where: {
lastRefresh: {
gte: onlineSince,
},
},
}),
ctx.db.generalAccessLog.findMany({
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }],
take: 5,
select: {
generalId: true,
refresh: true,
refreshScoreTotal: true,
},
}),
]);
const generalIds = topAccess.map((entry) => entry.generalId);
const generalRows =
generalIds.length > 0
? await ctx.db.general.findMany({
where: { id: { in: generalIds } },
select: { id: true, name: true },
})
: [];
const generalName = new Map(generalRows.map((general) => [general.id, general.name]));
const totalRefresh = accessTotal._sum.refresh ?? 0;
const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh);
const history = parseTrafficHistory(meta.recentTraffic);
history.push({
year: worldState.currentYear,
month: worldState.currentMonth,
refresh: currentRefresh,
online: currentOnline,
date: new Date().toISOString(),
});
return {
history,
maxRefresh: Math.max(
1,
readFiniteMetaNumber(meta, 'maxrefresh'),
...history.map((entry) => entry.refresh)
),
maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)),
suspects: [
{
generalId: null,
name: '접속자 총합',
refresh: totalRefresh,
refreshScoreTotal: totalRefreshScore,
},
...topAccess.map((entry) => ({
generalId: entry.generalId,
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
refresh: entry.refresh,
refreshScoreTotal: entry.refreshScoreTotal,
})),
],
};
}),
getGeneralList: procedure.query(async ({ ctx }) => { getGeneralList: procedure.query(async ({ ctx }) => {
const [generals, nations] = await Promise.all([ const [generals, nations] = await Promise.all([
ctx.db.general.findMany({ ctx.db.general.findMany({
+107 -54
View File
@@ -2,8 +2,11 @@ import { z } from 'zod';
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js'; import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js';
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { procedure, router } from '../../trpc.js'; import { authedProcedure, procedure, router } from '../../trpc.js';
const DEFAULT_BG_COLOR = '#2b2b2b'; const DEFAULT_BG_COLOR = '#2b2b2b';
const DEFAULT_FG_COLOR = '#ffffff'; const DEFAULT_FG_COLOR = '#ffffff';
@@ -23,31 +26,31 @@ const readMetaNumber = (value: unknown): number => {
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`; const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
const readOwnerDisplayName = (value: unknown): string | null => {
const meta = asRecord(value);
if (typeof meta.ownerName === 'string' && meta.ownerName.length > 0) {
return meta.ownerName;
}
if (typeof meta.owner_name === 'string' && meta.owner_name.length > 0) {
return meta.owner_name;
}
return null;
};
const itemLoader = new ItemLoader(); const itemLoader = new ItemLoader();
let cachedUniqueItems: Promise< let cachedUniqueItems: Promise<ItemModule[]> | null = null;
Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }>
> | null = null;
const loadUniqueItems = () => { const loadUniqueItems = () => {
if (!cachedUniqueItems) { if (!cachedUniqueItems) {
cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) => cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) =>
modules modules.filter((module) => module.unique && !module.buyable)
.filter((module) => module.unique && !module.buyable)
.map((module) => ({
key: module.key,
name: module.name,
slot: module.slot,
unique: module.unique,
buyable: module.buyable,
info: module.info,
}))
); );
} }
return cachedUniqueItems; return cachedUniqueItems;
}; };
export const rankingRouter = router({ export const rankingRouter = router({
getBestGeneral: procedure getBestGeneral: authedProcedure
.input( .input(
z z
.object({ .object({
@@ -57,7 +60,7 @@ export const rankingRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst({ const worldState = await ctx.db.worldState.findFirst({
select: { meta: true }, select: { meta: true, config: true },
}); });
const meta = asRecord(worldState?.meta); const meta = asRecord(worldState?.meta);
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
@@ -76,6 +79,7 @@ export const rankingRouter = router({
userId: true, userId: true,
picture: true, picture: true,
imageServer: true, imageServer: true,
meta: true,
experience: true, experience: true,
dedication: true, dedication: true,
horseCode: true, horseCode: true,
@@ -185,7 +189,7 @@ export const rankingRouter = router({
let display = { let display = {
id: general.id, id: general.id,
name: general.name, name: general.name,
ownerName: general.userId ?? null, ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR, bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR, fgColor: DEFAULT_FG_COLOR,
@@ -217,46 +221,91 @@ export const rankingRouter = router({
}); });
const uniqueItems = await loadUniqueItems(); const uniqueItems = await loadUniqueItems();
const itemEntries = uniqueItems.map((item) => { const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const owners = generals.filter((general) => { const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (item.slot === 'horse') { if (Object.keys(uniqueConfig.allItems).length === 0) {
return general.horseCode === item.key; uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
const auctionCounts = new Map<string, number>();
for (const auction of activeAuctions) {
if (auction.targetCode) {
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
}
}
const slotTitles = {
horse: '명 마',
weapon: '명 검',
book: '명 서',
item: '도 구',
} as const;
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
const item = itemRegistry.get(itemKey);
if (!item || item.buyable) {
return [];
} }
if (item.slot === 'weapon') { const owners = generals
return general.weaponCode === item.key; .filter((general) => {
if (slot === 'horse') {
return general.horseCode === itemKey;
}
if (slot === 'weapon') {
return general.weaponCode === itemKey;
}
if (slot === 'book') {
return general.bookCode === itemKey;
}
return general.itemCode === itemKey;
})
.map((general) => {
const nation = nationMap.get(general.nationId) ?? null;
return {
id: general.id,
name: general.name,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
};
});
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
owners.push({
id: 0,
name: '경매중',
nationName: '-',
bgColor: '#00582c',
fgColor: '#ffffff',
picture: null,
imageServer: 0,
});
} }
if (item.slot === 'book') { const count = Math.max(0, Math.floor(rawCount));
return general.bookCode === item.key; return Array.from({ length: count }, (_, index) => ({
} itemKey,
return general.itemCode === item.key; itemName: item.name,
itemInfo: item.info,
owner: owners[index] ?? {
id: 0,
name: '미발견',
nationName: '-',
bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
picture: null,
imageServer: 0,
},
}));
}); });
return { title: slotTitles[slot], slot, entries };
const displayOwners = owners.length
? owners.map((general) => {
const nation = nationMap.get(general.nationId) ?? null;
return {
id: general.id,
name: general.name,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
};
})
: [
{
id: 0,
name: '미발견',
nationName: '-',
bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
},
];
return {
title: item.name,
slot: item.slot,
owners: displayOwners,
};
}); });
return { return {
@@ -339,6 +388,10 @@ export const rankingRouter = router({
return { return {
generalId: row.generalNo, generalId: row.generalNo,
name: String(aux.name ?? ''), name: String(aux.name ?? ''),
ownerName:
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
? aux.ownerDisplayName
: null,
nationName: String(aux.nationName ?? ''), nationName: String(aux.nationName ?? ''),
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
+78 -1
View File
@@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js';
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import { loadWorldMap } from '../../maps/worldMap.js'; import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
const isWorldAdmin = (roles: readonly string[]): boolean => const isWorldAdmin = (roles: readonly string[]): boolean =>
@@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => {
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
}; };
const unitSetName = (world: WorldStateRow | null, fallback: string): string => {
const config = asRecord(world?.config);
const environment = asRecord(config.environment ?? config.map);
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
};
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
const loadCrewTypeNames = (name: string): Promise<Map<number, string>> => {
const cached = crewTypeNameCache.get(name);
if (cached) return cached;
const pending = loadUnitSetDefinitionByName(name)
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
.catch(() => new Map<number, string>());
crewTypeNameCache.set(name, pending);
return pending;
};
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
if (officerLevel === 12) return nationLevel * 2;
if (officerLevel >= 5) return nationLevel;
return 0;
};
const toWorldStateSnapshot = (row: WorldStateRow) => ({ const toWorldStateSnapshot = (row: WorldStateRow) => ({
scenarioCode: row.scenarioCode, scenarioCode: row.scenarioCode,
currentYear: row.currentYear, currentYear: row.currentYear,
@@ -122,6 +146,8 @@ export const worldRouter = router({
if (me.officerLevel > 0 && me.nationId > 0) { if (me.officerLevel > 0 && me.nationId > 0) {
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id)); cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
nationGenerals.forEach((general) => selectable.add(general.cityId)); nationGenerals.forEach((general) => selectable.add(general.cityId));
}
if ((nation?.level ?? 0) > 0) {
Object.keys(spy).forEach((id) => selectable.add(Number(id))); Object.keys(spy).forEach((id) => selectable.add(Number(id)));
} }
if (admin) cities.forEach((city) => selectable.add(city.id)); if (admin) cities.forEach((city) => selectable.add(city.id));
@@ -150,6 +176,8 @@ export const worldRouter = router({
turnMap.set(turn.generalId, list); turnMap.set(turn.generalId, list);
} }
const nationMap = new Map(nations.map((item) => [item.id, item])); const nationMap = new Map(nations.map((item) => [item.id, item]));
const selectedNation = nationMap.get(selected.nationId);
const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id));
const officers = await ctx.db.general.findMany({ const officers = await ctx.db.general.findMany({
where: { officerLevel: { in: [2, 3, 4] } }, where: { officerLevel: { in: [2, 3, 4] } },
select: { name: true, officerLevel: true, meta: true }, select: { name: true, officerLevel: true, meta: true },
@@ -175,14 +203,59 @@ export const worldRouter = router({
intelligence: general.intel, intelligence: general.intel,
injury: general.injury, injury: general.injury,
officerLevel: general.officerLevel, officerLevel: general.officerLevel,
leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0),
defenceTrain: ours ? defenceTrain(general.meta) : null, defenceTrain: ours ? defenceTrain(general.meta) : null,
crewTypeId: ours ? general.crewTypeId : null, crewTypeId: ours ? general.crewTypeId : null,
crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null,
crew: ours || full ? general.crew : null, crew: ours || full ? general.crew : null,
train: ours ? general.train : null, train: ours ? general.train : null,
atmos: ours ? general.atmos : null, atmos: ours ? general.atmos : null,
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [], turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
}; };
}); });
const forceSummary = mappedGenerals.reduce(
(summary, general) => {
if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) {
summary.enemyGenerals += 1;
if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew;
if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1;
return summary;
}
if (me.nationId <= 0 || general.nationId !== me.nationId) return summary;
summary.ownGenerals += 1;
summary.ownCrew += general.crew ?? 0;
if ((general.crew ?? 0) <= 0) return summary;
summary.ownArmedGenerals += 1;
const readiness = Math.min(general.train ?? -1, general.atmos ?? -1);
if (readiness >= 90) {
summary.ready90Crew += general.crew ?? 0;
summary.ready90Generals += 1;
}
if (readiness >= 60) {
summary.ready60Crew += general.crew ?? 0;
summary.ready60Generals += 1;
}
if (general.defenceTrain !== null && readiness >= general.defenceTrain) {
summary.defenceReadyCrew += general.crew ?? 0;
summary.defenceReadyGenerals += 1;
}
return summary;
},
{
enemyCrew: 0,
enemyArmedGenerals: 0,
enemyGenerals: 0,
ownCrew: 0,
ownArmedGenerals: 0,
ownGenerals: 0,
ready90Crew: 0,
ready90Generals: 0,
ready60Crew: 0,
ready60Generals: 0,
defenceReadyCrew: 0,
defenceReadyGenerals: 0,
}
);
return { return {
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin }, me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
options: [...selectable] options: [...selectable]
@@ -194,6 +267,7 @@ export const worldRouter = router({
id: selected.id, id: selected.id,
name: selected.name, name: selected.name,
nationId: selected.nationId, nationId: selected.nationId,
nationColor: selectedNation?.color ?? '#000000',
level: selected.level, level: selected.level,
region: selected.region, region: selected.region,
population: redact(selected.population), population: redact(selected.population),
@@ -217,8 +291,11 @@ export const worldRouter = router({
}, },
}, },
generals: mappedGenerals, generals: mappedGenerals,
forceSummary,
lastExecute: lastExecute:
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '', typeof asRecord(world?.meta).turntime === 'string'
? String(asRecord(world?.meta).turntime).slice(5, 19)
: '',
}; };
}), }),
getState: procedure.query(async ({ ctx }) => { getState: procedure.query(async ({ ctx }) => {
+98 -56
View File
@@ -1,15 +1,15 @@
import { createTournamentRng } from '@sammo-ts/common'; import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common';
import { resolveTournamentBattle } from '@sammo-ts/logic'; import { resolveTournamentBattle } from '@sammo-ts/logic';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
createRedisConnector, createRedisConnector,
resolvePostgresConfigFromEnv, resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv, resolveRedisConfigFromEnv,
type GamePrismaClient,
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
import { resolveGameApiConfigFromEnv } from '../config.js'; import { resolveGameApiConfigFromEnv } from '../config.js';
import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js'; import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
import type { TurnDaemonTransport } from '../daemon/transport.js'; import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js'; import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js'; import { TournamentStore } from './store.js';
@@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: {
return null; return null;
} }
let settledState: TournamentState | null = null; let settledState = state;
let changed = false;
if (!state.rewardSettled) { const requireSuccessfulResult = (
result: TurnDaemonCommandResult | null,
expectedType: TurnDaemonCommandResult['type']
): void => {
if (!result) {
throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`);
}
if (result.type !== expectedType) {
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
}
if (!result.ok) {
throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`);
}
};
if (!settledState.rewardSettled) {
const matches = await store.getMatches(); const matches = await store.getMatches();
const rewardPayload = buildTournamentRewardPayload(matches); const rewardPayload = buildTournamentRewardPayload(matches);
await daemonTransport.sendCommand({ const result = await daemonTransport.requestCommand({
type: 'tournamentReward', type: 'tournamentReward',
requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`,
tournamentType: state.type, tournamentType: state.type,
winnerId: rewardPayload.winnerId, winnerId: rewardPayload.winnerId,
runnerUpId: rewardPayload.runnerUpId, runnerUpId: rewardPayload.runnerUpId,
@@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: {
top8: rewardPayload.top8, top8: rewardPayload.top8,
top4: rewardPayload.top4, top4: rewardPayload.top4,
}); });
requireSuccessfulResult(result, 'tournamentReward');
settledState = { settledState = {
...(settledState ?? state), ...settledState,
rewardSettled: true, rewardSettled: true,
}; };
changed = true;
await store.setState(settledState);
} }
if (state.bettingId && !state.bettingSettled) { if (settledState.bettingId && !settledState.bettingSettled) {
const bettingEntries = await store.getBettingEntries(); const bettingEntries = await store.getBettingEntries();
if (bettingEntries.length > 0) { if (bettingEntries.length > 0) {
const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries); const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries);
if (payoutInfo.payouts.length > 0) { if (payoutInfo.payouts.length > 0) {
if (payoutInfo.refundAll) { if (payoutInfo.refundAll) {
await daemonTransport.sendCommand({ const result = await daemonTransport.requestCommand({
type: 'tournamentRefund', type: 'tournamentRefund',
bettingId: state.bettingId, requestId: `tournament:${settledState.bettingId}:betting-refund`,
bettingId: settledState.bettingId,
refunds: payoutInfo.payouts, refunds: payoutInfo.payouts,
reason: 'no_winner', reason: 'no_winner',
}); });
requireSuccessfulResult(result, 'tournamentRefund');
} else { } else {
await daemonTransport.sendCommand({ const result = await daemonTransport.requestCommand({
type: 'tournamentBettingPayout', type: 'tournamentBettingPayout',
bettingId: state.bettingId, requestId: `tournament:${settledState.bettingId}:betting-payout`,
bettingId: settledState.bettingId,
payouts: payoutInfo.payouts, payouts: payoutInfo.payouts,
reason: 'winner_payout', reason: 'winner_payout',
}); });
requireSuccessfulResult(result, 'tournamentBettingPayout');
} }
} }
} }
settledState = { settledState = {
...(settledState ?? state), ...settledState,
bettingSettled: true, bettingSettled: true,
}; };
} changed = true;
if (settledState) {
await store.setState(settledState); await store.setState(settledState);
} }
return settledState; return changed ? settledState : null;
};
const needsSettlement = (state: TournamentState): boolean =>
state.stage === 0 &&
Boolean(state.winnerId) &&
(!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled));
export const processTournamentTick = async (options: {
store: TournamentStore;
prisma: GamePrismaClient;
daemonTransport: TurnDaemonTransport;
now?: () => number;
}): Promise<TournamentState | null> => {
const { store, prisma, daemonTransport } = options;
const now = options.now ?? Date.now;
let processedState: TournamentState | null = null;
await store.withMutationLock(async () => {
const state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
return;
}
const nextAt = new Date(state.nextAt).getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
return;
}
if (needsSettlement(state)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return;
}
const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state;
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport);
}
processedState =
(await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
})) ?? nextState;
});
return processedState;
}; };
export const runTournamentWorker = async (): Promise<void> => { export const runTournamentWorker = async (): Promise<void> => {
@@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise<void> => {
await redis.connect(); await redis.connect();
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName)); const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
const daemonTransport = new RedisTurnDaemonTransport(redis.client, { const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
keys: buildTurnDaemonStreamKeys(config.profileName),
requestTimeoutMs: config.daemonRequestTimeoutMs,
});
const handleExit = async () => { const handleExit = async () => {
await redis.disconnect(); await redis.disconnect();
@@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise<void> => {
while (true) { while (true) {
const state = await store.getState(); const state = await store.getState();
if (!state || !state.auto) { if (!state || (!state.auto && !needsSettlement(state))) {
await sleepMs(config.tournamentPollMs); await sleepMs(config.tournamentPollMs);
continue; continue;
} }
const nextAt = new Date(state.nextAt).getTime(); const nextAt = new Date(state.nextAt).getTime();
const now = Date.now(); const now = Date.now();
if (Number.isFinite(nextAt) && nextAt > now) { if (state.auto && Number.isFinite(nextAt) && nextAt > now) {
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now)); await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
continue; continue;
} }
try { try {
await store.withMutationLock(async () => { await processTournamentTick({
const lockedState = await store.getState(); store,
if (!lockedState || !lockedState.auto) { prisma: postgres.prisma,
return; daemonTransport,
}
const lockedNextAt = new Date(lockedState.nextAt).getTime();
if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) {
return;
}
const worldState = await postgres.prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = lockedState;
if (isBattleStage(lockedState.stage)) {
nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(lockedState.stage)) {
nextState = await applyPreBattleStage(
store,
postgres.prisma,
lockedState,
String(baseSeed),
daemonTransport
);
}
await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
});
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
@@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise<void> => {
}, },
}, },
}); });
const currentState = (await store.getState()) ?? state;
const nextState: TournamentState = { const nextState: TournamentState = {
...state, ...currentState,
auto: false,
lastError: message, lastError: message,
lastErrorAt: now, lastErrorAt: now,
}; };
+3 -2
View File
@@ -520,8 +520,9 @@ export const buildBettingPayouts = (
const winners = entries.filter((entry) => entry.targetId === winnerId); const winners = entries.filter((entry) => entry.targetId === winnerId);
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0); const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
if (winnersTotal <= 0) { if (winnersTotal <= 0) {
const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })); // Legacy Betting::_calcRewardExclusive() builds a refund candidate list
return { payouts: refunds, total, refundAll: true }; // but returns no rewards when nobody selected the winner.
return { payouts: [], total, refundAll: false };
} }
const ratio = total / winnersTotal; const ratio = total / winnersTotal;
const payouts = winners.map((entry) => ({ const payouts = winners.map((entry) => ({
+20 -14
View File
@@ -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,
},
});
});
+2
View File
@@ -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),
}; };
}; };
+230 -21
View File
@@ -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();
}
});
});
+55 -13
View File
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
updatedAt: now, updatedAt: now,
...overrides, ...overrides,
}); });
const auth = (roles: string[] = []): GameSessionTokenPayload => ({ const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
version: 1, version: 1,
profile: 'che:default', profile: 'che:default',
issuedAt: now.toISOString(), issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(), expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: 'session', sessionId: 'session',
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles }, user: { id: userId, username: 'tester', displayName: 'Tester', roles },
sanctions: {}, sanctions: {},
}); });
const city = (id: number, nationId: number) => ({ const city = (id: number, nationId: number) => ({
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
meta: {}, meta: {},
}); });
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => { const context = (
options: {
me?: GeneralRow;
roles?: string[];
userId?: string;
nationMeta?: Record<string, unknown>;
nationLevel?: number;
stationCityId?: number;
} = {}
) => {
const me = options.me ?? general(); const me = options.me ?? general();
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)]; const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 }); const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
const db = { const db = {
general: { general: {
findFirst: vi.fn(async () => me), findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
where.userId === me.userId ? me : null
),
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => { findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }]; if (args.where?.nationId === 1 && args.select?.cityId)
return [
{ cityId: me.cityId },
...(options.stationCityId ? [{ cityId: options.stationCityId }] : []),
];
if (args.where?.cityId === 2) return [foreign]; if (args.where?.cityId === 2) return [foreign];
if (args.where?.cityId === 3) return [foreign]; if (args.where?.cityId === 3) return [foreign];
if (args.where?.officerLevel) return []; if (args.where?.officerLevel) return [];
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
id: 1, id: 1,
name: '아국', name: '아국',
color: '#008000', color: '#008000',
level: 1, level: options.nationLevel ?? 1,
capitalCityId: 1, capitalCityId: 1,
meta: options.nationMeta ?? {}, meta: options.nationMeta ?? {},
})), })),
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
turnDaemon: {} as GameApiContext['turnDaemon'], turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'], battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' }, profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: auth(options.roles), auth: auth(options.roles, options.userId),
uploadDir: 'uploads', uploadDir: 'uploads',
uploadPath: '/uploads', uploadPath: '/uploads',
uploadPublicUrl: null, uploadPublicUrl: null,
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
expect(result.generals).toEqual([]); expect(result.generals).toEqual([]);
}); });
it('derives the actor from the session user instead of accepting another user general', async () => {
const caller = appRouter.createCaller(context({ userId: 'user-2' }));
await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('allows a nation member to select a city occupied by another general of the same nation', async () => {
const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 });
expect(result.options.map((entry) => entry.id)).toContain(2);
expect(result.visibility.full).toBe(true);
});
it('does not grant a spy city while the nation has no active level', async () => {
const result = await appRouter
.createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 }))
.world.getCurrentCity({ cityId: 2 });
expect(result.options.map((entry) => entry.id)).not.toContain(2);
expect(result.visibility.full).toBe(false);
});
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => { it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
const result = await appRouter const result = await appRouter
.createCaller(context({ me: general({ cityId: 80 }) })) .createCaller(context({ me: general({ cityId: 80 }) }))
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
expect(result.visibility.full).toBe(true); expect(result.visibility.full).toBe(true);
expect(result.city.population).toBe(1000); expect(result.city.population).toBe(1000);
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null }); expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
expect(result.forceSummary).toMatchObject({
enemyCrew: 777,
enemyArmedGenerals: 1,
enemyGenerals: 1,
});
}); });
it('allows administrative roles to inspect all city and general fields', async () => { it.each(['admin', 'superuser', 'admin.superuser'])(
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 }); 'allows the %s role to inspect all city and general fields',
expect(result.options).toHaveLength(4); async (role) => {
expect(result.visibility.full).toBe(true); const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); expect(result.options).toHaveLength(4);
}); expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
}
);
}); });
@@ -0,0 +1,257 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const now = new Date('2026-01-01T00:00:00.000Z');
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-7',
name: '검증장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: 'default.jpg',
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 10,
dedication: 20,
officerLevel: 1,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 0,
train: 80,
atmos: 80,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {
belong: 1,
permission: 'normal',
myset: 3,
tnmt: 0,
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
},
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
sessionId: 'session-7',
user: { id: 'user-7', username: 'tester', displayName: 'Tester', roles: [] },
sanctions: {},
};
const createContext = (options: {
me?: GeneralRow;
targets?: GeneralRow[];
nationMeta?: Record<string, unknown>;
requestCommand?: ReturnType<typeof vi.fn>;
}) => {
const me = options.me ?? buildGeneral();
const targets = options.targets ?? [me];
const requestCommand =
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id }));
const generalFindUnique = vi.fn(
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
);
const db = {
general: {
findFirst: vi.fn(async () => me),
findUnique: generalFindUnique,
findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)),
update: vi.fn(),
},
city: { findUnique: vi.fn(async () => null) },
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '위',
color: '#777777',
level: 3,
gold: 10_000,
rice: 20_000,
tech: 100,
typeCode: 'che_법가',
capitalCityId: 1,
meta: options.nationMeta ?? { secretlimit: 3 },
})),
},
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
})),
},
logEntry: {
groupBy: vi.fn(async () => []),
findMany: vi.fn(async () => [{ id: 1, text: '기록' }]),
},
};
const redisClient = { get: async () => null, set: async () => null };
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, db, requestCommand };
};
describe('in-game my information ownership', () => {
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand });
const caller = appRouter.createCaller(fixture.context);
const me = await caller.general.me();
expect(me?.settings).toEqual({
tnmt: 0,
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
myset: 3,
});
await caller.general.setMySetting({ tnmt: 1, defence_train: 999 });
expect(requestCommand).toHaveBeenCalledWith({
type: 'setMySetting',
generalId: 7,
settings: { tnmt: 1, defence_train: 999 },
});
expect(fixture.db.general.update).not.toHaveBeenCalled();
});
it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => {
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.general.me()).resolves.toMatchObject({
general: { id: 7, name: '검증장수' },
});
await expect(caller.general.getMyLog({ type: 'generalAction' })).resolves.toMatchObject({
type: 'generalAction',
logs: [{ id: 1 }],
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-7' },
})
);
expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ generalId: 7 }),
})
);
});
});
describe('battle-center general and user permissions', () => {
it('distinguishes an ordinary member, a tenured member, and an auditor', async () => {
const ordinary = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 1, permission: 'normal' } }),
nationMeta: { secretlimit: 3 },
});
await expect(appRouter.createCaller(ordinary.context).nation.getBattleCenter()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
const tenured = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }),
nationMeta: { secretlimit: 3 },
});
await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({
me: { id: 7, permissionLevel: 1 },
});
const auditor = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }),
nationMeta: { secretlimit: 3 },
});
await expect(appRouter.createCaller(auditor.context).nation.getBattleCenter()).resolves.toMatchObject({
me: { id: 7, permissionLevel: 3 },
});
});
it('redacts another user action log while allowing own, NPC, chief, and non-private logs', async () => {
const me = buildGeneral({ meta: { belong: 3, permission: 'normal' } });
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저', npcState: 0 });
const npc = buildGeneral({ id: 9, userId: null, name: 'NPC', npcState: 2 });
const foreign = buildGeneral({ id: 10, userId: 'user-10', name: '타국', nationId: 2 });
const memberFixture = createContext({
me,
targets: [me, otherUser, npc, foreign],
nationMeta: { secretlimit: 3 },
});
const member = appRouter.createCaller(memberFixture.context);
await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({
generalId: me.id,
});
await expect(
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'battleDetail' })
).resolves.toMatchObject({ generalId: otherUser.id });
await expect(member.nation.getGeneralLog({ generalId: npc.id, type: 'generalAction' })).resolves.toMatchObject({
generalId: npc.id,
});
await expect(
member.nation.getGeneralLog({ generalId: foreign.id, type: 'battleDetail' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
const chiefFixture = createContext({
me: buildGeneral({ officerLevel: 5 }),
targets: [buildGeneral({ officerLevel: 5 }), otherUser],
nationMeta: { secretlimit: 3 },
});
await expect(
appRouter
.createCaller(chiefFixture.context)
.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
).resolves.toMatchObject({ generalId: otherUser.id });
});
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 45,
intel: 85,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-07-26T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'che_선봉',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-07-26T00:00:00Z'),
updatedAt: new Date('2026-07-26T00:00:00Z'),
...overrides,
});
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
});
const worldState = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 4,
tickSeconds: 3600,
config: {
const: {
availableSpecialWar: ['che_선봉'],
allItems: {
weapon: {
che_무기_12_칠성검: 1,
che_무기_01_단도: 0,
},
},
},
},
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
updatedAt: new Date('2026-07-26T00:00:00Z'),
};
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
target?: GeneralRow | null;
inheritancePoint?: number;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const target =
options.target === undefined
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
: options.target;
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
type: command.type,
ok: true,
generalId: command.generalId,
}));
const pointUpsert = vi.fn(async () => ({}));
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
const db = {
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
worldState: {
findFirst: vi.fn(async () => worldState),
},
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
general?.userId === where.userId ? general : null
),
findMany,
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
target?.id === where.id ? target : null
),
},
inheritancePoint: {
upsert: pointUpsert,
},
inheritanceLog: {
create: logCreate,
findMany: vi.fn(async () => []),
},
inheritanceUserState: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async () => ({})),
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, pointUpsert, logCreate, findMany };
};
describe('inherit router actor and permission boundaries', () => {
it('rejects unauthenticated status and mutations', async () => {
const fixture = buildContext({ auth: null });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
const fixture = buildContext({});
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
expect(status.availableUnique).toEqual([
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
]);
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
expect(fixture.findMany).toHaveBeenCalledWith({
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
});
});
it('does not dispatch or charge when the authenticated user owns no general', async () => {
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }),
});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '장수가 존재하지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
});
it('mutates only the authenticated user general and inheritance balance', async () => {
const fixture = buildContext({ inheritancePoint: 1000 });
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).resolves.toEqual({ ok: true, remainPoint: 800 });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'patchGeneral',
generalId: 7,
patch: expect.objectContaining({
meta: expect.objectContaining({
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
}),
}),
})
);
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 800 },
})
);
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({ inheritancePoint: 1500 });
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
).resolves.toEqual({
ok: true,
ownerName: '위유저',
targetName: '조조',
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 500 },
})
);
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
},
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
});
+336 -3
View File
@@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = {
sanctions: {}, sanctions: {},
}; };
const buildContext = (overrides: Record<string, unknown> = {}) => { const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async () => 1); const executeRaw = vi.fn(async () => 1);
const updateMany = vi.fn(async () => ({ count: 1 })); const updateMany = vi.fn(async () => ({ count: 1 }));
const db = { const db = {
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
$executeRaw: executeRaw, $executeRaw: executeRaw,
...overrides, ...overrides,
}; };
const redis = {
set: vi.fn(async () => 'OK'),
publish: vi.fn(async () => 1),
};
const context = { const context = {
db, db,
auth, auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' }, profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {}, redis,
turnDaemon: {}, turnDaemon: {},
battleSim: {}, battleSim: {},
uploadDir: 'uploads', uploadDir: 'uploads',
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
accessTokenStore: {}, accessTokenStore: {},
flushStore: {}, flushStore: {},
gameTokenSecret: 'test-secret', gameTokenSecret: 'test-secret',
...contextOverrides,
} as unknown as GameApiContext; } as unknown as GameApiContext;
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany }; return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis };
}; };
describe('messages router missing-flow compatibility', () => { describe('messages router missing-flow compatibility', () => {
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
expect(result.canRespondDiplomacy).toBe(true); expect(result.canRespondDiplomacy).toBe(true);
}); });
it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => ambassador),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const result = await caller.messages.getRecent({ generalId: ambassador.id });
expect(result.permission).toBe(4);
expect(result.canRespondDiplomacy).toBe(false);
});
it('redacts recent and old diplomacy content below secret permission 3', async () => {
const diplomacyRow = {
id: 19,
mailbox: 9001,
type: 'diplomacy',
src: 9002,
dest: 9001,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: 8,
generalName: '외교관',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 1,
nationName: '위',
color: '#ffffff',
icon: '',
},
text: '보이면 안 되는 외교 본문',
option: { action: 'noAggression' },
},
};
const queryRaw = vi.fn(async () => [diplomacyRow]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const recent = await caller.messages.getRecent({ generalId: general.id });
const old = await caller.messages.getOld({
generalId: general.id,
type: 'diplomacy',
to: 20,
});
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
});
expect(old.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
});
});
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
const queryRaw = vi.fn(async () => [{ id: 51 }]);
const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
}));
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: findNation,
},
});
const result = await caller.messages.send({
generalId: general.id,
mailbox: 9002,
text: '국가 메시지',
});
expect(result.msgType).toBe('national');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
});
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const queryRaw = vi.fn(async () => [{ id: 52 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
general: {
findUnique: vi.fn(async () => ambassador),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
})),
},
});
const result = await caller.messages.send({
generalId: ambassador.id,
mailbox: 9002,
text: '외교 메시지',
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
});
it('blocks private messages between foreign ambassadors', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const foreignAmbassador = {
...ambassador,
id: 8,
userId: 'user-8',
name: '상대 외교관',
nationId: 2,
} as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
where.id === ambassador.id ? ambassador : foreignAmbassador
),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
})),
},
});
await expect(
caller.messages.send({
generalId: ambassador.id,
mailbox: foreignAmbassador.id,
text: '개인 메시지',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
});
});
it.each([
['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'],
['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'],
])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => {
const penalized = { ...general, penalty } as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => penalized),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
},
});
await expect(
caller.messages.send({
generalId: penalized.id,
mailbox,
text: '차단 메시지',
})
).rejects.toMatchObject({ code: 'FORBIDDEN', message });
});
it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => {
const redis = {
set: vi.fn(async () => null),
publish: vi.fn(async () => 1),
};
const { caller } = buildContext(
{
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
},
},
{ redis }
);
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 8,
text: '너무 빠른 메시지',
})
).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
message: '개인메세지는 2초당 1건만 보낼 수 있습니다!',
});
});
it('blocks sends for a muted authenticated user independently of general permission', async () => {
const mutedAuth = {
...auth,
sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' },
};
const { caller } = buildContext({}, { auth: mutedAuth });
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 9999,
text: '사용자 mute',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
});
});
it('rejects every remaining general-scoped message mutation for another user general', async () => {
const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => foreignGeneral),
findMany: vi.fn(async () => []),
},
});
await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.readLatest({
generalId: foreignGeneral.id,
type: 'private',
messageId: 1,
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.respond({
generalId: foreignGeneral.id,
messageId: 1,
response: true,
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('persists latest-read updates through the monotonic upsert', async () => { it('persists latest-read updates through the monotonic upsert', async () => {
const { caller, executeRaw } = buildContext(); const { caller, executeRaw } = buildContext();
@@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => {
}); });
}); });
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 25,
mailbox: 9001,
type: 'diplomacy',
src: 9001,
dest: 9002,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [25] } },
data: { validUntil: expect.any(Date) },
});
});
it('rejects deleting another general message', async () => { it('rejects deleting another general message', async () => {
const queryRaw = vi.fn(async () => [ const queryRaw = vi.fn(async () => [
{ {
@@ -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 });
});
}); });
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import { appRouter } from '../src/router.js';
const now = new Date('2026-01-01T01:02:00Z');
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'u1',
name: '장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 900,
dedication: 100,
officerLevel: 1,
gold: 1000,
rice: 2000,
crew: 300,
crewTypeId: 1,
train: 90,
atmos: 90,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { belong: 1, defence_train: 80, killturn: 7 },
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const token = (userId: string): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: userId,
user: { id: userId, username: userId, displayName: userId, roles: [] },
sanctions: {},
});
const fixture = (generals: GeneralRow[], userId = 'u1') => {
const db = {
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
generals.find((g) => g.userId === where.userId)
),
findMany: vi.fn(async ({ where }: { where: { nationId: number } }) =>
generals.filter((g) => g.nationId === where.nationId)
),
},
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '위',
color: '#080',
level: 3,
typeCode: 'che_중립',
capitalCityId: 1,
meta: { secretlimit: 3 },
})),
},
city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) },
troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) },
worldState: { findFirst: vi.fn(async () => null) },
generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) },
generalAccessLog: {
findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))),
},
};
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: token(userId),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'secret',
};
return { caller: appRouter.createCaller(context), db };
};
describe('nation general and secret office permissions', () => {
it('redacts ordinary-member details and denies the secret office', async () => {
const { caller } = fixture([general()]);
const result = await caller.nation.getGeneralList();
expect(result.viewer).toEqual({ generalId: 1, permission: 0 });
expect(result.generals[0]).toMatchObject({
officerLevel: 1,
cityName: null,
troopName: null,
refreshScoreTotal: 10,
});
expect(result.generals[0]).not.toHaveProperty('crew');
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('uses the session-owned general and scopes secret rows to that nation', async () => {
const first = general();
const actor = general({ id: 2, userId: 'u2', officerLevel: 5, meta: { belong: 1 } });
const ally = general({ id: 3, userId: 'u3', gold: 3000, crew: 200, train: 80, atmos: 80 });
const foreign = general({ id: 4, userId: 'u4', nationId: 2, gold: 99999 });
const { caller, db } = fixture([first, actor, ally, foreign], 'u2');
const result = await caller.nation.getSecretGeneralList();
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } });
expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } }));
});
it('honors general penalties after a user switch', async () => {
const penalized = general({ officerLevel: 5, penalty: { noChief: true } });
const { caller } = fixture([penalized]);
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
});
+275
View File
@@ -0,0 +1,275 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const baseGeneral: GeneralRow = {
id: 22,
userId: 'user-22',
name: '정책담당',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: 'default.jpg',
imageServer: 0,
leadership: 70,
strength: 70,
intel: 70,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 12,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-01-01T00:00:00.000Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { belong: 5, permission: 'normal' },
penalty: {},
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-02T00:00:00.000Z',
sessionId: 'session-22',
user: { id: 'user-22', username: 'tester', displayName: 'Tester', roles: [] },
sanctions: {},
};
const baseNation = {
id: 1,
name: '위',
level: 3,
tech: 3_000,
meta: {
_updatedAt: '2026-01-01T00:00:00.000Z',
npc_nation_policy: {
values: { reqNationRice: 456 },
priority: ['천도', '천도'],
},
npc_general_policy: {
priority: ['출병', '일반내정', '출병'],
},
},
};
const baseWorld = {
config: {
stat: { max: 80, npcMax: 75 },
environment: { unitSet: 'basic' },
const: { develCost: 100 },
},
meta: {
npc_nation_policy: { values: { reqNationGold: 123 } },
npc_general_policy: {},
},
};
const createContext = (
options: {
me?: GeneralRow;
nation?: typeof baseNation;
world?: typeof baseWorld;
requestCommand?: ReturnType<typeof vi.fn>;
troopRows?: Array<{ troopLeaderId: number }>;
cityRows?: Array<{ id: number }>;
} = {}
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
const requestCommand =
options.requestCommand ??
vi.fn(async () => ({
type: 'setNationMeta',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:01:00.000Z',
}));
const findFirst = vi.fn(async () => options.me ?? baseGeneral);
const db = {
general: { findFirst },
nation: { findUnique: vi.fn(async () => options.nation ?? baseNation) },
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
};
const redisClient = { get: async () => null, set: async () => null };
return {
context: {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
},
findFirst,
requestCommand,
};
};
describe('NPC policy router', () => {
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
const fixture = createContext();
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
expect(fixture.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-22' } });
expect(result.currentNationPolicy).toMatchObject({ reqNationGold: 123, reqNationRice: 456 });
expect(result.currentNationPriority).toEqual(['천도', '천도']);
expect(result.currentGeneralActionPriority).toEqual(['출병', '일반내정', '출병']);
expect(result.zeroPolicy).toMatchObject({
reqNationGold: 10_000,
reqNationRice: 12_000,
reqNPCDevelGold: 3_000,
reqNPCWarGold: 3_900,
reqNPCWarRice: 3_900,
reqHumanWarUrgentGold: 6_300,
reqHumanWarUrgentRice: 6_300,
reqHumanWarRecommandGold: 12_600,
reqHumanWarRecommandRice: 12_600,
});
});
it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => {
const reader = { ...baseGeneral, officerLevel: 2 };
const fixture = createContext({ me: reader });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 });
await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it.each([
['군주', { ...baseGeneral, officerLevel: 12 }],
['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }],
['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }],
])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => {
const fixture = createContext({ me });
await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({
ok: true,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
nationId: 1,
updates: {
npc_nation_policy: expect.objectContaining({
priority: ['천도', '천도'],
prioritySetter: '정책담당',
prioritySetTime: expect.any(String),
}),
},
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
});
});
it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.setNationPolicy({
reqNationGold: -100,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_nation_policy: expect.objectContaining({
values: expect.objectContaining({
reqNationGold: 0,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
}),
}),
},
})
);
fixture.requestCommand.mockClear();
await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']);
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_general_policy: expect.objectContaining({
priority: ['출병', '출병', '일반내정'],
}),
},
})
);
await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => {
const nationless = createContext({ me: { ...baseGeneral, nationId: 0, officerLevel: 0 } });
await expect(appRouter.createCaller(nationless.context).npc.getPolicy()).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
const penalized = createContext({ me: { ...baseGeneral, penalty: { noChief: true } } });
await expect(appRouter.createCaller(penalized.context).npc.getPolicy()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
const staleCommand = vi.fn(async () => ({
type: 'setNationMeta',
ok: false,
nationId: 1,
reason: 'CONFLICT',
}));
const stale = createContext({ requestCommand: staleCommand });
await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({
code: 'CONFLICT',
});
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const buildContext = (): GameApiContext => {
const db = {
worldState: {
findFirst: async () => ({
id: 1,
currentYear: 185,
currentMonth: 3,
tickSeconds: 600,
config: {},
meta: {
lastTurnTime: '2026-07-26T03:00:00.000Z',
refresh: 12,
maxrefresh: 30,
maxonline: 5,
recentTraffic: [
{
year: 185,
month: 2,
refresh: 30,
online: 5,
date: '2026-07-26 02:50:00',
},
],
},
}),
},
generalAccessLog: {
aggregate: async () => ({
_sum: {
refresh: 12,
refreshScoreTotal: 21,
},
}),
count: async (args: { where: { lastRefresh: { gte: Date } } }) => {
expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z'));
return 2;
},
findMany: async () => [
{ generalId: 7, refresh: 9, refreshScoreTotal: 15 },
{ generalId: 8, refresh: 3, refreshScoreTotal: 6 },
],
},
general: {
findMany: async () => [
{ id: 7, name: '갑' },
{ id: 8, name: '을' },
],
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('public.getTraffic', () => {
it('is public and returns only aggregate traffic plus allowlisted general names', async () => {
const result = await appRouter.createCaller(buildContext()).public.getTraffic();
expect(result.history).toHaveLength(2);
expect(result.history[0]).toEqual({
year: 185,
month: 2,
refresh: 30,
online: 5,
date: '2026-07-26 02:50:00',
});
expect(result.history[1]).toMatchObject({
year: 185,
month: 3,
refresh: 12,
online: 2,
});
expect(result.maxRefresh).toBe(30);
expect(result.maxOnline).toBe(5);
expect(result.suspects).toEqual([
{ generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 },
{ generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 },
{ generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 },
]);
expect(JSON.stringify(result)).not.toContain('userId');
});
});
+248
View File
@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'ranking-session',
user: {
id: 'request-user-id',
username: 'ranking-user',
displayName: '조회자',
roles: [],
},
sanctions: {},
};
const generalRows = [
{
id: 1,
name: '유비',
nationId: 1,
userId: 'private-user-id-1',
npcState: 0,
picture: '1.jpg',
imageServer: 0,
meta: { ownerName: '공개소유자' },
experience: 1200,
dedication: 900,
horseCode: 'che_명마_15_적토마',
weaponCode: 'None',
bookCode: 'None',
itemCode: 'None',
},
{
id: 2,
name: '빙의관우',
nationId: 1,
userId: 'private-user-id-2',
npcState: 1,
picture: null,
imageServer: 0,
meta: { owner_name: '빙의소유자' },
experience: 1100,
dedication: 800,
horseCode: 'None',
weaponCode: 'None',
bookCode: 'None',
itemCode: 'None',
},
{
id: 3,
name: 'NPC조조',
nationId: 2,
userId: null,
npcState: 2,
picture: null,
imageServer: 0,
meta: {},
experience: 1300,
dedication: 1000,
horseCode: 'None',
weaponCode: 'None',
bookCode: 'None',
itemCode: 'None',
},
] as const;
const buildContext = (options?: {
authenticated?: boolean;
isUnited?: boolean;
includeOwnerDisplayName?: boolean;
}): GameApiContext => {
const db = {
worldState: {
findFirst: async () => ({
meta: { isUnited: options?.isUnited ? 1 : 0 },
config: {
const: {
allItems: {
horse: { che_명마_15_적토마: 2 },
weapon: {},
book: {},
item: {},
},
},
},
}),
},
nation: {
findMany: async () => [
{ id: 1, name: '촉', color: '#006400' },
{ id: 2, name: '위', color: '#8b0000' },
],
},
general: {
findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) =>
generalRows.filter((general) =>
args.where.npcState.gte !== undefined
? general.npcState >= args.where.npcState.gte
: general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY)
),
},
rankData: {
findMany: async () => [
{ generalId: 1, type: 'firenum', value: 10 },
{ generalId: 2, type: 'firenum', value: 20 },
{ generalId: 3, type: 'firenum', value: 30 },
],
},
auction: {
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
},
gameHistory: {
findMany: async () => [
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
],
},
hallOfFame: {
findMany: async (args: { where: { type: string } }) =>
args.where.type === 'experience'
? [
{
generalNo: 1,
value: 1200,
aux: {
name: '유비',
ownerName: 'private-hall-user-id',
...(options?.includeOwnerDisplayName ? { ownerDisplayName: '공개소유자' } : {}),
nationName: '촉',
bgColor: '#006400',
fgColor: '#ffffff',
},
},
]
: [],
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: options?.authenticated === false ? null : auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('ranking.getBestGeneral', () => {
it('requires a game login even though the ranking is the same for every authenticated user', async () => {
await expect(
appRouter.createCaller(buildContext({ authenticated: false })).ranking.getBestGeneral({ view: 'user' })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('keeps possessed generals in the user view and redacts account identifiers before unification', async () => {
const result = await appRouter.createCaller(buildContext({ isUnited: false })).ranking.getBestGeneral({
view: 'user',
});
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([1, 2]);
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual([null, null]);
expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries).toEqual([
expect.objectContaining({ id: 2, name: '???', nationName: '???', ownerName: null }),
expect.objectContaining({ id: 1, name: '???', nationName: '???', ownerName: null }),
]);
expect(JSON.stringify(result)).not.toContain('private-user-id');
});
it('uses display names only after unification and preserves configured item copies plus auctions', async () => {
const result = await appRouter.createCaller(buildContext({ isUnited: true })).ranking.getBestGeneral({
view: 'user',
});
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual(['공개소유자', '빙의소유자']);
expect(result.uniqueItems.find((section) => section.slot === 'horse')?.entries).toEqual([
expect.objectContaining({
itemKey: 'che_명마_15_적토마',
owner: expect.objectContaining({ id: 1, name: '유비' }),
}),
expect.objectContaining({
itemKey: 'che_명마_15_적토마',
owner: expect.objectContaining({ id: 0, name: '경매중' }),
}),
]);
expect(JSON.stringify(result)).not.toContain('private-user-id');
});
it('separates autonomous NPCs from users and possessed generals', async () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
});
});
describe('ranking hall of fame', () => {
it('remains public and groups scenario counts', async () => {
const options = await appRouter
.createCaller(buildContext({ authenticated: false }))
.ranking.getHallOfFameOptions();
expect(options).toEqual([
{
season: 3,
scenarios: [{ id: 22, name: '가상모드22', count: 2 }],
},
]);
});
it('returns an explicit display name but never exposes the stored account identifier', async () => {
const result = await appRouter
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
.ranking.getHallOfFame({ season: 3 });
expect(result.sections[0]?.entries[0]?.ownerName).toBe('공개소유자');
expect(JSON.stringify(result)).not.toContain('private-hall-user-id');
const redacted = await appRouter
.createCaller(buildContext({ authenticated: false }))
.ranking.getHallOfFame({ season: 3 });
expect(redacted.sections[0]?.entries[0]?.ownerName).toBeNull();
});
});
+111 -3
View File
@@ -12,6 +12,7 @@ import type {
TournamentState, TournamentState,
} from '../src/tournament/types.js'; } from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js'; import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
import { buildBettingPayouts } from '../src/tournament/workerHelpers.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis { class MemoryRedis {
@@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0)); const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker (in-memory)', () => { describe('tournament worker (in-memory)', () => {
it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => {
expect(
buildBettingPayouts(10, [
{ generalId: 1, targetId: 11, amount: 100 },
{ generalId: 2, targetId: 12, amount: 200 },
])
).toEqual({ payouts: [], total: 300, refundAll: false });
});
it('locks 64 applicants into eight groups of eight', async () => { it('locks 64 applicants into eight groups of eight', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-groups')); const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
@@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => {
const sent: TurnDaemonCommand[] = []; const sent: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = { const transport: TurnDaemonTransport = {
sendCommand: async (command) => { sendCommand: async () => 'unused',
requestCommand: async (command) => {
sent.push(command); sent.push(command);
return 'ok'; if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: command.payouts.length,
missing: 0,
totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0),
};
}
return null;
}, },
requestCommand: async () => null,
requestStatus: async () => null, requestStatus: async () => null,
}; };
@@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => {
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') { if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]); expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]);
} }
expect(await store.getState()).toMatchObject({
rewardSettled: true,
bettingSettled: true,
});
});
it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry'));
const state = createTournamentState({
stage: 0,
auto: false,
winnerId: 10,
bettingId: 124,
rewardSettled: false,
bettingSettled: false,
});
await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]);
await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]);
await store.setState(state);
let payoutAttempts = 0;
const commands: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async () => 'unused',
requestCommand: async (command) => {
commands.push(command);
if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
payoutAttempts += 1;
if (payoutAttempts === 1) {
return {
type: 'tournamentBettingPayout',
ok: false,
bettingId: command.bettingId,
reason: '일시적 실패',
};
}
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: 1,
missing: 0,
totalPayout: 100,
};
}
return null;
},
requestStatus: async () => null,
};
await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow(
'일시적 실패'
);
const afterFailure = await store.getState();
expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false });
await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! });
expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true });
expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1);
expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2);
expect(
commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId)
).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']);
}); });
it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => { it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => {
@@ -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'],
@@ -809,9 +809,35 @@ async function handleVacation(
if (!general) { if (!general) {
return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
} }
const autorunUser = asRecord(world.getState().meta.autorun_user);
if (autorunUser.limit_minutes) {
return {
type: 'vacation',
ok: false,
generalId: command.generalId,
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
};
}
const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0);
world.updateGeneral(general.id, {
meta: {
...general.meta,
killturn: killturn * 3,
},
});
return { type: 'vacation', ok: true, generalId: command.generalId }; return { type: 'vacation', ok: true, generalId: command.generalId };
} }
const normalizeDefenceTrain = (value: number): number => {
if (value <= 40) {
return 40;
}
if (value <= 90) {
return Math.round(value / 10) * 10;
}
return 999;
};
async function handleSetMySetting( async function handleSetMySetting(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }> command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
@@ -826,11 +852,48 @@ async function handleSetMySetting(
reason: '장수 정보를 찾을 수 없습니다.', reason: '장수 정보를 찾을 수 없습니다.',
}; };
} }
const settings = command.settings;
const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80);
const nextDefenceTrain =
settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train);
const nextMeta = { ...general.meta };
if (settings.tnmt !== undefined) {
nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt;
}
if (settings.use_treatment !== undefined) {
nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment));
}
if (settings.use_auto_nation_turn !== undefined) {
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn;
}
let nextTrain = general.train;
let nextAtmos = general.atmos;
if (nextDefenceTrain !== previousDefenceTrain) {
nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1;
nextMeta.defence_train = nextDefenceTrain;
if (nextDefenceTrain === 999) {
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
const ignoresPenalty =
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
scenarioEffect === 'event_StrongAttacker' ||
scenarioEffect === 'event_MoreEffect';
const constValues = asRecord(world.getScenarioConfig().const);
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
const trainDelta = ignoresPenalty ? 0 : -3;
const atmosDelta = ignoresPenalty ? 0 : -6;
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta));
}
}
world.updateGeneral(command.generalId, { world.updateGeneral(command.generalId, {
meta: { meta: nextMeta,
...general.meta, train: nextTrain,
...command.settings, atmos: nextAtmos,
},
}); });
return { type: 'setMySetting', ok: true, generalId: command.generalId }; return { type: 'setMySetting', ok: true, generalId: command.generalId };
} }
@@ -844,10 +907,8 @@ async function handleDropItem(
if (!general) { if (!general) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
} }
const slot = (['horse', 'weapon', 'book', 'item'] as const).find( const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType);
(candidate) => general.role.items[candidate] === command.itemType if (!slot || !general.role.items[slot]) {
);
if (!slot) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' }; return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
} }
const nextGeneral = { const nextGeneral = {
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id: 7,
userId: 'user-7',
name: '테스트장수',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
turnTime: new Date('0185-01-01T00:00:00Z'),
recentWarTime: null,
role: {
items: { horse: 'che_명마', weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
killturn: 12,
myset: 3,
defence_train: 80,
tnmt: 0,
use_treatment: 10,
use_auto_nation_turn: 1,
},
penalty: {},
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 0,
train: 90,
atmos: 90,
age: 20,
npcState: 0,
...overrides,
});
const buildWorld = (
general = buildGeneral(),
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
) => {
const state: TurnWorldState = {
id: 1,
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
meta: {
killturn: 24,
autorun_user: options.autorunLimit ? { limit_minutes: 60 } : {},
},
};
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: { maxTrainByWar: 100, maxAtmosByWar: 100 },
environment: {
mapName: 'test',
unitSet: 'test',
...(options.scenarioEffect !== undefined ? { scenarioEffect: options.scenarioEffect } : {}),
},
},
scenarioMeta: {
title: 'test',
startYear: 180,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
return { world, handler: createTurnDaemonCommandHandler({ world }) };
};
describe('my information world commands', () => {
it('normalizes legacy settings and charges myset only when defence mode changes', async () => {
const fixture = buildWorld();
await expect(
fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: {
tnmt: 9,
defence_train: 94,
use_treatment: 200,
use_auto_nation_turn: 0,
},
})
).resolves.toMatchObject({ ok: true });
expect(fixture.world.getGeneralById(7)).toMatchObject({
train: 87,
atmos: 84,
meta: {
tnmt: 1,
defence_train: 999,
use_treatment: 100,
use_auto_nation_turn: 0,
myset: 2,
},
});
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { tnmt: 0, defence_train: 999, use_treatment: 1 },
});
expect(fixture.world.getGeneralById(7)?.meta).toMatchObject({
tnmt: 0,
use_treatment: 10,
myset: 2,
});
});
it('preserves the event scenarios that waive the no-defence penalty', async () => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { defence_train: 999 },
});
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
});
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
const allowed = buildWorld();
await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true });
expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72);
const blocked = buildWorld(buildGeneral(), { autorunLimit: true });
await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({
ok: false,
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
});
expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12);
});
it('drops only the authenticated command target slot and rejects an empty slot', async () => {
const fixture = buildWorld();
await expect(
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' })
).resolves.toMatchObject({ ok: false });
await expect(
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' })
).resolves.toMatchObject({ ok: true });
expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull();
});
});
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest';
import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { AutorunNationPolicy } from '../src/turn/ai/policies.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const general: TurnGeneral = {
id: 1,
userId: 'owner-1',
name: 'NPC군주',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 75, strength: 40, intelligence: 70 },
turnTime: new Date('0185-01-01T00:00:00Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
penalty: {},
officerLevel: 12,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 1100,
train: 0,
atmos: 0,
age: 30,
npcState: 2,
};
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [
{
id: 1,
name: '허창',
nationId: 1,
level: 7,
state: 0,
population: 100_000,
populationMax: 200_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: {},
},
],
nations: [
{
id: 1,
name: '위',
color: '#777777',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 10_000,
rice: 20_000,
power: 0,
level: 3,
typeCode: 'che_법가',
meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' },
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'basic' },
},
scenarioMeta: {
title: 'test',
startYear: 180,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
const state: TurnWorldState = {
id: 1,
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
meta: { killturn: 24 },
};
const commandEnv: TurnCommandEnv = {
baseGold: 1_000,
baseRice: 1_000,
develCost: 18,
maxResourceActionAmount: 10_000,
minAvailableRecruitPop: 30_000,
trainDelta: 5,
atmosDelta: 5,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
sabotageDefaultProb: 0.5,
sabotageProbCoefByStat: 0.01,
sabotageDefenceCoefByGeneralCount: 0.01,
sabotageDamageMin: 1,
sabotageDamageMax: 10,
defaultCrewTypeId: 1100,
maxGeneral: 100,
defaultNpcGold: 1_000,
defaultNpcRice: 1_000,
defaultSpecialDomestic: null,
defaultSpecialWar: null,
openingPartYear: 3,
initialNationGenLimit: 10,
maxTechLevel: 10,
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
};
const unitSet: UnitSetDefinition = {
id: 'basic',
name: 'basic',
defaultCrewTypeId: 1100,
armTypes: { 1: '보병' },
crewTypes: [
{
id: 1100,
armType: 1,
name: '보병',
attack: 100,
defence: 150,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 9,
rice: 9,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
};
describe('NPC policy lifecycle', () => {
it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => {
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
const updates = {
npc_nation_policy: {
values: { reqNationGold: 4_321 },
priority: ['천도'],
valueSetter: '정책담당',
},
};
await expect(
handler.handle({
type: 'setNationMeta',
nationId: 1,
updates,
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
})
).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 });
const nation = world.getNationById(1)!;
expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy });
const policy = new AutorunNationPolicy({
general: world.getGeneralById(1)!,
aiOptions: null,
nationPolicy: asRecord(nation.meta).npc_nation_policy as Record<string, unknown>,
serverPolicy: null,
nation,
env: commandEnv,
scenarioConfig: snapshot.scenarioConfig,
unitSet,
});
expect(policy.reqNationGold).toBe(4_321);
expect(policy.priority).toEqual(['천도']);
expect(policy.reqNpcDevelGold).toBe(540);
expect(policy.reqNpcWarGold).toBe(3_900);
expect(policy.reqNpcWarRice).toBe(3_900);
await expect(
handler.handle({
type: 'setNationMeta',
nationId: 1,
updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } },
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
})
).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' });
expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({
reqNationGold: 4_321,
});
expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 });
});
});
@@ -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',
});
}
});
+192 -11
View File
@@ -1,6 +1,28 @@
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const response = (data: unknown) => ({ result: { data } }); const response = (data: unknown) => ({ result: { data } });
const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR;
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const readImage = async (relativePath: string): Promise<Buffer> => {
if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`);
for (const root of imageRoots) {
try {
return await readFile(resolve(root, relativePath));
} catch {
// Product checkout and feature worktrees have different image-root parents.
}
}
throw new Error(`Fixture image not found: ${relativePath}`);
};
const imageContentType = (relativePath: string) => {
if (relativePath.endsWith('.png')) return 'image/png';
if (relativePath.endsWith('.gif')) return 'image/gif';
return 'image/jpeg';
};
const operationNames = (route: Route) => const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const city = { const city = {
@@ -46,19 +68,68 @@ const layout = {
regionMap: { 1: '하북' }, regionMap: { 1: '하북' },
levelMap: { 8: '특' }, levelMap: { 8: '특' },
}; };
const generalContext = {
general: {
id: 1,
name: '장수',
nationId: 1,
cityId: 1,
officerLevel: 1,
npcState: 0,
troopId: 0,
picture: null,
imageServer: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
gold: 1000,
rice: 1000,
crew: 500,
train: 90,
atmos: 90,
injury: 0,
experience: 0,
dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
},
city,
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
settings: {},
penalties: {},
};
const emptyMessages = {
private: [],
national: [],
public: [],
diplomacy: [],
sequence: -1,
hasMore: { private: false, national: false, public: false, diplomacy: false },
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
canRespondDiplomacy: false,
};
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => { const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_info'); localStorage.setItem('sammo-game-token', 'ga_info');
localStorage.setItem('sammo-game-profile', 'che:default'); localStorage.setItem('sammo-game-profile', 'che:default');
}); });
await page.route('**/image/game/**', (route) => await page.route('**/image/**', async (route) => {
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? '');
); await route.fulfill({
status: 200,
contentType: imageContentType(relativePath),
body: await readImage(relativePath),
});
});
await page.route('**/che/api/trpc/**', async (route) => { await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => { const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
if (operation === 'join.getConfig') return response({}); if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMap') return response(map);
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral') return response([]);
if (operation === 'messages.getRecent') return response(emptyMessages);
if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'nation.getNationInfo') if (operation === 'nation.getNationInfo')
return response({ return response({
nation: { nation: {
@@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
id: 1, id: 1,
name: '업', name: '업',
nationId: 1, nationId: 1,
nationColor: '#008000',
level: 8, level: 8,
region: 1, region: 1,
population: mode === 'wanderer' ? null : 150000, population: mode === 'wanderer' ? null : 150000,
@@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
intelligence: 50, intelligence: 50,
injury: 0, injury: 0,
officerLevel: 1, officerLevel: 1,
leadershipBonus: 0,
defenceTrain: 80, defenceTrain: 80,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병',
crew: 500, crew: 500,
train: 90, train: 90,
atmos: 90, atmos: 90,
turns: ['징병'], turns: ['징병'],
}, },
], ],
forceSummary: {
enemyCrew: 0,
enemyArmedGenerals: 0,
enemyGenerals: 0,
ownCrew: mode === 'wanderer' ? 0 : 500,
ownArmedGenerals: mode === 'wanderer' ? 0 : 1,
ownGenerals: mode === 'wanderer' ? 0 : 1,
ready90Crew: mode === 'wanderer' ? 0 : 500,
ready90Generals: mode === 'wanderer' ? 0 : 1,
ready60Crew: mode === 'wanderer' ? 0 : 500,
ready60Generals: mode === 'wanderer' ? 0 : 1,
defenceReadyCrew: mode === 'wanderer' ? 0 : 500,
defenceReadyGenerals: mode === 'wanderer' ? 0 : 1,
},
lastExecute: '2026-07-26', lastExecute: '2026-07-26',
}); });
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
@@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => {
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => { test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
for (const [path, selector] of [ for (const [path, selector, fontSize, fontFamily, borderCollapse] of [
['nation/info', '.legacy-info-page'], ['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
['nation/cities', '.nation-cities-page'], ['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
['global-info', '.global-page'], ['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
['current-city', '.city-page'], ['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
] as const) { ] as const) {
await go(page, path); await go(page, path);
await expect(page.locator(selector)).toBeVisible(); await expect(page.locator(selector)).toBeVisible();
@@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
}); });
expect(box.width).toBe(1000); expect(box.width).toBe(1000);
expect(box.x).toBe(100); expect(box.x).toBe(100);
expect(box.fontSize).toBe('14px'); expect(box.fontSize).toBe(fontSize);
expect(box.fontFamily).toContain('Pretendard'); expect(box.fontFamily).toContain(fontFamily);
expect( expect(
await page await page
.locator('table') .locator('table')
.first() .first()
.evaluate((el) => getComputedStyle(el).borderCollapse) .evaluate((el) => getComputedStyle(el).borderCollapse)
).toBe('collapse'); ).toBe(borderCollapse);
} }
}); });
@@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => { test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
await install(page, 'admin'); await install(page, 'admin');
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'current-city'); await go(page, 'current-city');
await expect(page.locator('.generals')).toContainText('장수'); await expect(page.locator('.generals')).toContainText('장수');
await expect(page.locator('.generals')).toContainText('90'); await expect(page.locator('.generals')).toContainText('90');
const legacyGeometry = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector(selector)?.getBoundingClientRect();
return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null;
};
const icon = document.querySelector<HTMLImageElement>('.general-icon');
return {
selector: rect('#citySelector'),
stats: rect('.stats'),
generals: rect('.generals'),
titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign,
icon: icon
? {
...rect('.general-icon'),
naturalWidth: icon.naturalWidth,
naturalHeight: icon.naturalHeight,
}
: null,
};
});
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 });
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 });
expect(legacyGeometry.titleAlign).toBe('start');
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
const computedDom = await page.evaluate(() => {
const measure = (selector: string) => {
const element = document.querySelector(selector);
if (!element) return null;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderCollapse: style.borderCollapse,
padding: style.padding,
textAlign: style.textAlign,
},
};
};
const icon = document.querySelector<HTMLImageElement>('.general-icon');
return {
body: measure('body'),
page: measure('.city-page'),
selector: measure('#citySelector'),
stats: measure('.stats'),
generals: measure('.generals'),
title: measure('.city-title'),
firstIcon: icon
? {
...measure('.general-icon'),
naturalWidth: icon.naturalWidth,
naturalHeight: icon.naturalHeight,
}
: null,
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
});
await writeFile(
resolve(artifactRoot, 'core-current-city-computed-dom.json'),
`${JSON.stringify(computedDom, null, 2)}\n`
);
await page.screenshot({
path: resolve(artifactRoot, 'core-current-city-desktop.png'),
fullPage: true,
animations: 'disabled',
});
}
});
test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, '');
const cityLink = page.locator('.map-city').first();
await expect(cityLink).toBeVisible();
await cityLink.hover();
await expect(cityLink).toHaveCSS('cursor', 'pointer');
await cityLink.click();
await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/);
await expect(page.locator('.stats')).toContainText('업');
}); });
+375
View File
@@ -0,0 +1,375 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT;
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => {
if (!parityArtifactDir) {
return;
}
await mkdir(parityArtifactDir, { recursive: true });
await Promise.all([
page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }),
writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
]);
};
type FixtureState = {
permission: 'head' | 'member';
myset: number;
settingMutations: Array<Record<string, unknown>>;
};
const myGeneral = (state: FixtureState) => ({
general: {
id: 7,
name: '검증장수',
npcState: 0,
nationId: 1,
cityId: 1,
troopId: 0,
picture: null,
imageServer: 0,
officerLevel: state.permission === 'head' ? 5 : 1,
stats: { leadership: 70, strength: 60, intelligence: 50 },
gold: 1_000,
rice: 2_000,
crew: 300,
train: 80,
atmos: 90,
injury: 0,
experience: 100,
dedication: 200,
items: { horse: 'che_명마', weapon: null, book: null, item: null },
},
city: { id: 1, name: '업', level: 8, nationId: 1 },
nation: { id: 1, name: '위', color: '#777777', level: 3 },
settings: {
tnmt: 0,
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
myset: state.myset,
},
penalties: {},
});
const battleCenter = (state: FixtureState) => ({
me: {
id: 7,
officerLevel: state.permission === 'head' ? 5 : 1,
permissionLevel: state.permission === 'head' ? 2 : 0,
},
nation: { id: 1, name: '위', color: '#777777', level: 3 },
currentYear: 185,
currentMonth: 1,
turnTermMinutes: 10,
generals: [
{
id: 7,
name: '검증장수',
npcState: 0,
officerLevel: state.permission === 'head' ? 5 : 1,
cityId: 1,
turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00',
warnum: 3,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 100,
dedication: 200,
injury: 0,
gold: 1_000,
rice: 2_000,
crew: 300,
train: 80,
atmos: 90,
},
{
id: 8,
name: '다른장수',
npcState: 2,
officerLevel: 1,
cityId: 1,
turnTime: '2026-01-01 00:20:00',
recentWar: null,
warnum: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0,
dedication: 0,
injury: 0,
gold: 500,
rice: 500,
crew: 100,
train: 60,
atmos: 60,
},
],
});
const install = async (page: Page, state: FixtureState) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'menu-token');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', async (route) => {
const filename = basename(new URL(route.request().url()).pathname);
if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) {
await route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: await readFile(resolve(legacyImageRoot, filename)),
});
return;
}
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response(myGeneral(state));
if (operation === 'world.getState')
return response({
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
config: { npcMode: 0, const: { availableInstantAction: {} } },
meta: {
turntime: '2026-01-01T00:00:00.000Z',
opentime: '2025-12-01T00:00:00.000Z',
autorun_user: {},
},
});
if (operation === 'public.getTraffic')
return response({
history: [
{ year: 185, month: 1, date: '2026-01-01T00:00:00.000Z', refresh: 120, online: 8 },
{ year: 185, month: 2, date: '2026-01-01T00:10:00.000Z', refresh: 240, online: 12 },
],
maxRefresh: 240,
maxOnline: 12,
suspects: [
{ generalId: null, name: '합계', refresh: 360, refreshScoreTotal: 36 },
{ generalId: 7, name: '검증장수', refresh: 240, refreshScoreTotal: 24 },
],
});
if (operation === 'general.getMyLog')
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
if (operation === 'general.setMySetting') {
const raw = route.request().postDataJSON() as { input?: { json?: Record<string, unknown> } };
state.settingMutations.push(raw.input?.json ?? {});
state.myset = Math.max(0, state.myset - 1);
return response({ ok: true });
}
if (operation === 'nation.getBattleCenter') {
if (state.permission === 'member') {
return {
error: {
message: '권한이 부족합니다.',
code: -32000,
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
},
};
}
return response(battleCenter(state));
}
if (operation === 'nation.getGeneralLog') {
const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction')
? 'generalAction'
: operation;
return response({ type, generalId: 7, logs: [{ id: 1, text: '<Y>감찰 기록</>' }] });
}
return response({ ok: true });
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operations.length === 1 ? results[0] : results),
});
});
};
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('traffic');
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
const geometry = await page.locator('#traffic-container').evaluate((element) => {
const rect = element.getBoundingClientRect();
const title = element.querySelector<HTMLElement>('.title-table')!.getBoundingClientRect();
const charts = [...element.querySelectorAll<HTMLElement>('.chart-table')].map((chart) =>
chart.getBoundingClientRect()
);
const row = element.querySelector<HTMLElement>('.chart-row')!.getBoundingClientRect();
const bar = element.querySelector<HTMLElement>('.big-bar')!.getBoundingClientRect();
const suspect = element.querySelector<HTMLElement>('.suspect-table')!.getBoundingClientRect();
return {
width: rect.width,
minWidth: getComputedStyle(element).minWidth,
fontSize: getComputedStyle(element).fontSize,
fontFamily: getComputedStyle(element).fontFamily,
titleWidth: title.width,
chartWidths: charts.map((chart) => chart.width),
chartGap: charts[1]!.x - charts[0]!.right,
rowHeight: row.height,
barHeight: bar.height,
suspectWidth: suspect.width,
};
});
expect(geometry.width).toBe(1016);
expect(geometry.minWidth).toBe('1016px');
expect(geometry.fontSize).toBe('14px');
expect(geometry.fontFamily).toContain('Pretendard');
expect(geometry.titleWidth).toBe(1000);
expect(geometry.chartWidths).toEqual([483, 483]);
expect(geometry.chartGap).toBe(26);
expect(geometry.rowHeight).toBe(31);
expect(geometry.barHeight).toBe(30);
expect(geometry.suspectWidth).toBeGreaterThanOrEqual(994);
await persistParityArtifact(page, 'traffic-desktop', geometry);
await page.setViewportSize({ width: 500, height: 900 });
const mobileWidth = await page
.locator('#traffic-container')
.evaluate((element) => element.getBoundingClientRect().width);
expect(mobileWidth).toBe(1016);
});
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('my-page');
await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible();
const desktop = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
const title = element.querySelector<HTMLElement>('.title-row')!.getBoundingClientRect();
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
const save = saveButton.getBoundingClientRect();
const customCss = element.querySelector<HTMLElement>('#custom_css')!.getBoundingClientRect();
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
return {
width: rect.width,
minWidth: getComputedStyle(element).minWidth,
fontSize: getComputedStyle(element).fontSize,
columns,
titleHeight: title.height,
settingsOffset: settings.x - rect.x,
saveWidth: save.width,
saveHeight: save.height,
saveBackground: getComputedStyle(saveButton).backgroundColor,
customCssWidth: customCss.width,
customCssHeight: customCss.height,
backgroundImage: getComputedStyle(element).backgroundImage,
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
};
});
expect(desktop.width).toBe(1000);
expect(desktop.minWidth).toBe('500px');
expect(desktop.fontSize).toBe('14px');
expect(desktop.columns.split(' ')).toHaveLength(2);
expect(desktop.titleHeight).toBeCloseTo(54, 0);
expect(desktop.settingsOffset).toBeCloseTo(500, 0);
expect(desktop.saveWidth).toBe(160);
expect(desktop.saveHeight).toBe(30);
expect(desktop.saveBackground).toBe('rgb(34, 85, 0)');
expect(desktop.customCssWidth).toBe(420);
expect(desktop.customCssHeight).toBe(150);
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
await page
.locator('select')
.filter({ has: page.locator('option[value="999"]') })
.selectOption('999');
await page.locator('#set_my_setting').click();
await expect.poll(() => state.settingMutations.length).toBe(1);
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
await page.setViewportSize({ width: 500, height: 900 });
await page.reload();
const mobile = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
return {
width: rect.width,
scrollWidth: document.documentElement.scrollWidth,
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
settingsOffset: settings.x - rect.x,
settingsWidth: settings.width,
};
});
expect(mobile).toMatchObject({
width: 500,
scrollWidth: 500,
columns: '500px',
settingsOffset: 0,
settingsWidth: 500,
});
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
});
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
await install(page, head);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('battle-center');
await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible();
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
await page.getByRole('button', { name: '다음 ▶' }).click();
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
const geometry = await page.locator('.battle-page').evaluate((element) => {
const selector = element.querySelector<HTMLElement>('.selector-row')!;
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
const logBlock = element.querySelector<HTMLElement>('.log-block')!.getBoundingClientRect();
return {
width: element.getBoundingClientRect().width,
fontSize: getComputedStyle(element).fontSize,
selectorColumns: getComputedStyle(selector).gridTemplateColumns,
selectorHeight: selector.getBoundingClientRect().height,
controlWidths: controls.map((control) => control.width),
logBlockWidth: logBlock.width,
backgroundImage: getComputedStyle(element).backgroundImage,
generalBackgroundImage: getComputedStyle(element.querySelector<HTMLElement>('.battle-general-card')!)
.backgroundImage,
};
});
expect(geometry.width).toBe(1000);
expect(geometry.fontSize).toBe('14px');
expect(geometry.selectorColumns.split(' ')).toHaveLength(4);
expect(geometry.selectorHeight).toBeCloseTo(36, 0);
expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0);
expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0);
expect(geometry.logBlockWidth).toBeCloseTo(500, 0);
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.generalBackgroundImage).toContain('back_blue.jpg');
await persistParityArtifact(page, 'core-battle-center-desktop', geometry);
await page.setViewportSize({ width: 500, height: 900 });
const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({
columns: getComputedStyle(element).gridTemplateColumns,
controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
}));
expect(mobileGeometry.columns.split(' ')).toHaveLength(4);
expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0);
expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0);
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
await page.unrouteAll({ behavior: 'wait' });
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] };
await install(page, member);
await page.reload();
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
});
@@ -0,0 +1,150 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operations = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const general = {
id: 1,
name: '테스트장수',
npcState: 0,
officerLevel: 1,
cityId: 1,
cityName: null,
troopId: 0,
troopName: null,
officerCity: 0,
officerCityName: null,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experienceLevel: 9,
dedicationLevel: 1,
injury: 0,
gold: 1000,
rice: 2000,
personality: null,
specialDomestic: null,
specialWar: null,
belong: 1,
refreshScoreTotal: 10,
permission: 'normal',
};
const install = async (page: Page, secretAllowed = true) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_general');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/che/api/trpc/**', async (route) => {
const results = operations(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '테스트장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getGeneralList')
return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 0 },
generals: [general],
});
if (operation === 'nation.getSecretGeneralList') {
if (!secretAllowed)
return {
error: {
message: '권한이 부족합니다.',
code: -32000,
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
},
};
return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 1 },
summary: {
gold: 1000,
rice: 2000,
crew: 300,
generalCount: 1,
averageGold: 1000,
averageRice: 2000,
readiness: {
90: { crew: 300, generals: 1 },
80: { crew: 300, generals: 1 },
60: { crew: 300, generals: 1 },
},
},
generals: [
{
id: 1,
name: '테스트장수',
npcState: 0,
injury: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
leadershipBonus: 0,
experienceLevel: 9,
troopId: 0,
troopName: null,
gold: 1000,
rice: 2000,
cityId: 1,
cityName: '업',
defenceTrain: 90,
defenceTrainText: '☆',
crewTypeId: 1,
crew: 300,
train: 90,
atmos: 90,
killTurn: 7,
turnTime: '2026-01-01T01:02:00.000Z',
reservedCommands: ['징병', '훈련'],
},
],
});
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('nation generals keeps the 1000px legacy grid and redacted member columns', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
await expect(page.locator('#nation-general-list')).toContainText('?');
const computed = await page.locator('.general-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
});
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
expect(computed.fontFamily).toContain('Times New Roman');
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
'separate'
);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
});
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
for (const path of ['nation/generals', 'nation/secret']) {
await page.goto(path);
await expect(
page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list')
).toBeVisible();
expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
}
});
test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/secret');
await expect(page.locator('.summary')).toContainText('전체 금');
await expect(page.locator('#secret-general-list')).toContainText('1 : 징병');
expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
await page.unroute('**/che/api/trpc/**');
await install(page, false);
await page.goto('nation/secret');
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
await expect(page.locator('#secret-general-list')).toHaveCount(0);
});
+338
View File
@@ -0,0 +1,338 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
type FixtureState = {
permissionLevel: number;
failNextMutation?: boolean;
failLoad?: boolean;
mutations: string[];
};
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR
? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR)
: null;
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const referenceAsset = async (relativePath: string): Promise<Buffer> => {
for (const root of imageRoots) {
try {
return await readFile(resolve(root, relativePath));
} catch {
// Nested worktrees and the primary checkout have different image parents.
}
}
throw new Error(`Reference image not found: ${relativePath}`);
};
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({
error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } },
});
const operationName = (route: Route): string => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6));
};
const fulfillJson = (route: Route, body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
const nationPriority = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
];
const generalPriority = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
];
const policy = {
reqNationGold: 10_000,
reqNationRice: 12_000,
CombatForce: {},
SupportForce: [],
DevelopForce: [],
reqHumanWarUrgentGold: 0,
reqHumanWarUrgentRice: 0,
reqHumanWarRecommandGold: 0,
reqHumanWarRecommandRice: 0,
reqHumanDevelGold: 10_000,
reqHumanDevelRice: 10_000,
reqNPCWarGold: 0,
reqNPCWarRice: 0,
reqNPCDevelGold: 0,
reqNPCDevelRice: 500,
minimumResourceActionAmount: 1_000,
maximumResourceActionAmount: 10_000,
minNPCWarLeadership: 40,
minWarCrew: 1_500,
minNPCRecruitCityPopulation: 50_000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
cureThreshold: 10,
};
const policyFixture = (state: FixtureState) => ({
nationId: 1,
nationName: '위',
nationLevel: 3,
defaultNationPolicy: policy,
currentNationPolicy: policy,
zeroPolicy: {
...policy,
reqHumanWarUrgentGold: 7_600,
reqHumanWarUrgentRice: 7_600,
reqHumanWarRecommandGold: 15_200,
reqHumanWarRecommandRice: 15_200,
reqNPCWarGold: 2_700,
reqNPCWarRice: 2_700,
reqNPCDevelGold: 540,
},
defaultNationPriority: nationPriority,
currentNationPriority: nationPriority,
availableNationPriorityItems: nationPriority,
defaultGeneralActionPriority: generalPriority,
currentGeneralActionPriority: generalPriority,
availableGeneralActionPriorityItems: generalPriority,
lastSetters: {
policy: { setter: null, date: null },
nation: { setter: null, date: null },
general: { setter: null, date: null },
},
defaultStatMax: 70,
defaultStatNpcMax: 75,
permissionLevel: state.permissionLevel,
});
const installFixture = async (page: Page, state: FixtureState) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_npc_policy_playwright');
localStorage.setItem('sammo-game-profile', 'che:default');
});
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
await page.route(`**/image/game/${filename}`, async (route) =>
route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: await referenceAsset(`game/${filename}`),
})
);
}
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationName(route).split(',');
const results = operations.map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'npc.getPolicy') {
return state.failLoad
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
: response(policyFixture(state));
}
if (
operation === 'npc.setNationPolicy' ||
operation === 'npc.setNationPriority' ||
operation === 'npc.setGeneralPriority'
) {
state.mutations.push(operation);
if (state.failNextMutation) {
state.failNextMutation = false;
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
}
return response({ ok: true });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
});
await fulfillJson(route, results);
});
};
const gotoPolicy = async (page: Page) => {
await page.goto('npc-control');
};
const screenshot = async (page: Page, name: string) => {
if (!artifactRoot) return;
await mkdir(artifactRoot, { recursive: true });
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
};
test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
page,
}) => {
const state: FixtureState = { permissionLevel: 4, mutations: [] };
await installFixture(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await gotoPolicy(page);
await expect(page.locator('#container')).toBeVisible();
const computed = await page.evaluate(() => {
const measure = (selector: string) => {
const element = document.querySelector<HTMLElement>(selector)!;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
display: style.display,
gridTemplateColumns: style.gridTemplateColumns,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundImage: style.backgroundImage,
backgroundColor: style.backgroundColor,
};
};
return {
body: measure('body'),
container: measure('#container'),
topBar: measure('.top-back-bar'),
section: measure('.section_bar'),
form: measure('.form_list'),
field: measure('.policy-field'),
input: measure('.field-row input'),
control: measure('.control_bar'),
reset: measure('.reset_btn'),
submit: measure('.submit_btn'),
priorityPanel: measure('.priority-panel'),
priorityList: measure('.priority-list'),
inactiveHeader: measure('.inactive-header'),
activeItem: measure('.priority-column:nth-child(2) .priority-item'),
help: measure('.help-button'),
documentWidth: document.documentElement.scrollWidth,
};
});
expect(computed.body).toMatchObject({ width: 1000, fontSize: '14px', lineHeight: '21px' });
expect(computed.body.fontFamily).toContain('Pretendard');
expect(computed.container).toMatchObject({ x: 0, y: 32, width: 1000 });
expect(computed.container.backgroundImage).toContain('back_walnut.jpg');
expect(computed.topBar).toMatchObject({ width: 1000, height: 32 });
expect(computed.section).toMatchObject({ x: 1, y: 33, width: 998, height: 23 });
expect(computed.section.backgroundImage).toContain('back_green.jpg');
expect(computed.form).toMatchObject({ x: 9, width: 982 });
expect(computed.form.gridTemplateColumns).toBe('491px 491px');
expect(computed.field.width).toBeCloseTo(491, 0);
expect(computed.input).toMatchObject({ width: 224, height: 34 });
expect(computed.reset).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(48, 48, 48)' });
expect(computed.submit).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(55, 90, 127)' });
expect(computed.priorityPanel.width).toBeCloseTo(499, 0);
expect(computed.priorityList.width).toBeCloseTo(229, 0);
expect(computed.inactiveHeader).toMatchObject({ height: 37, backgroundColor: 'rgb(214, 214, 214)' });
expect(computed.activeItem.height).toBe(37);
expect(computed.help).toMatchObject({ width: 24, height: 22.375 });
expect(computed.documentWidth).toBe(1000);
await screenshot(page, 'core-npc-policy-desktop-baseline.png');
const goldInput = page.getByLabel('국가 권장 금');
await goldInput.focus();
await expect(goldInput).toBeFocused();
expect(await goldInput.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none');
const help = page.getByRole('button', { name: '불가침제의 설명' });
await help.hover();
await expect.poll(() => help.evaluate((element) => getComputedStyle(element, '::after').opacity)).toBe('1');
const active = page.locator('.priority-panel').first().locator('.priority-column').nth(1).getByText('불가침제의');
await active.dragTo(
page.locator('.priority-panel').first().locator('.priority-column').first().locator('.priority-list')
);
await expect(
page.locator('.priority-panel').first().locator('.priority-column').first().getByText('불가침제의')
).toBeVisible();
await goldInput.fill('12345');
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.');
expect(state.mutations).toContain('npc.setNationPolicy');
await screenshot(page, 'core-npc-policy-desktop.png');
});
test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => {
await installFixture(page, { permissionLevel: 4, mutations: [] });
await page.setViewportSize({ width: 500, height: 900 });
await gotoPolicy(page);
await expect(page.locator('#container')).toBeVisible();
const geometry = await page.evaluate(() => {
const rect = (selector: string) => {
const value = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: value.x, y: value.y, width: value.width, height: value.height };
};
return {
container: rect('#container'),
form: rect('.form_list'),
firstField: rect('.policy-field'),
panels: [...document.querySelectorAll<HTMLElement>('.priority-panel')].map((element) => {
const value = element.getBoundingClientRect();
return { x: value.x, y: value.y, width: value.width };
}),
documentWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.container).toMatchObject({ x: 0, y: 32, width: 500 });
expect(geometry.form).toMatchObject({ x: 9, width: 482 });
expect(geometry.firstField.width).toBeCloseTo(482, 0);
expect(geometry.panels).toHaveLength(2);
expect(geometry.panels[0]).toMatchObject({ x: 1, width: 498 });
expect(geometry.panels[1]?.x).toBe(1);
expect(geometry.panels[1]?.y).toBeGreaterThan(geometry.panels[0]?.y ?? 0);
expect(geometry.documentWidth).toBe(500);
await screenshot(page, 'core-npc-policy-mobile.png');
});
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
await installFixture(page, state);
await gotoPolicy(page);
const input = page.getByLabel('국가 권장 금');
await expect(input).toBeEnabled();
await input.fill('23456');
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
await expect(input).toHaveValue('23456');
expect(state.mutations).toEqual(['npc.setNationPolicy']);
});
test('a user below secret read permission receives a recoverable page error', async ({ page }) => {
await installFixture(page, { permissionLevel: 0, failLoad: true, mutations: [] });
await gotoPolicy(page);
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
await expect(page.locator('#container')).toHaveCount(0);
await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible();
});
+11 -1
View File
@@ -8,7 +8,17 @@ const baseURL = `http://127.0.0.1:${port}/che/`;
export default defineConfig({ export default defineConfig({
testDir: '.', testDir: '.',
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], testMatch: [
'troop.spec.ts',
'board.spec.ts',
'inGameInfo.spec.ts',
'inGameMenus.spec.ts',
'nationOffices.spec.ts',
'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts',
'battleSimulator.spec.ts',
'battleSimulatorRef.spec.ts',
],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
timeout: 30_000, timeout: 30_000,
+2
View File
@@ -9,7 +9,9 @@
"preview": "vite preview", "preview": "vite preview",
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
"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: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;
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
</script> </script>
<template> <template>
<div <RouterLink
class="map-city" class="map-city"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:class="[ :class="[
`state-${props.city.stateClass}`, `state-${props.city.stateClass}`,
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }, { mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
@@ -45,20 +46,22 @@ const stateOffset = computed(() => -6 * props.mapScale);
@mouseleave="emit('leave')" @mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)" @click.stop="emit('select', props.city.id)"
> >
<div <div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
class="city-dot"
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
>
<span v-if="props.city.isCapital" class="capital" /> <span v-if="props.city.isCapital" class="capital" />
</div> </div>
<div <div
v-if="props.city.state > 0" v-if="props.city.state > 0"
class="city-state" class="city-state"
:class="`state-${props.city.stateClass}`" :class="`state-${props.city.stateClass}`"
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }" :style="{
width: `${stateSize}px`,
height: `${stateSize}px`,
left: `${stateOffset}px`,
top: `${stateOffset}px`,
}"
/> />
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div> <div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</div> </RouterLink>
</template> </template>
<style scoped> <style scoped>
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: 0.65rem; font-size: 0.65rem;
color: rgba(232, 221, 196, 0.8); color: rgba(232, 221, 196, 0.8);
cursor: pointer;
text-decoration: none;
} }
.city-dot { .city-dot {
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
</script> </script>
<template> <template>
<div <RouterLink
class="city-base" class="city-base"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]" :class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
:style="cityBaseStyle" :style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)" @mouseenter="emit('hover', props.city.id)"
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle"> <div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" /> <img :src="stateIcon" />
</div> </div>
</div> </RouterLink>
</template> </template>
<style scoped> <style scoped>
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: 0.65rem; font-size: 0.65rem;
color: rgba(232, 221, 196, 0.9); color: rgba(232, 221, 196, 0.9);
cursor: pointer;
text-decoration: none;
} }
.city-bg { .city-bg {
@@ -1,13 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, reactive } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import type { MessageType } from '@sammo-ts/logic'; import type { MessageType } from '@sammo-ts/logic';
import SkeletonLines from '../ui/SkeletonLines.vue';
import MessagePlate from './MessagePlate.vue';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry { interface MessageEntry {
id: number; id: number;
text: string; text: string;
time: string; time: string;
msgType: MessageType; msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null; option?: Record<string, unknown> | null;
} }
@@ -16,6 +28,22 @@ interface MessageBucket {
public: MessageEntry[]; public: MessageEntry[];
national: MessageEntry[]; national: MessageEntry[];
diplomacy: MessageEntry[]; diplomacy: MessageEntry[];
permission: number;
latestRead: {
private: number;
diplomacy: number;
};
}
interface MailboxGroup {
label: string;
color?: string;
options: Array<{
label: string;
value: number;
disabled?: boolean;
color?: string;
}>;
} }
const props = defineProps<{ const props = defineProps<{
@@ -23,7 +51,10 @@ const props = defineProps<{
loading: boolean; loading: boolean;
targetMailbox: number; targetMailbox: number;
draftText: string; draftText: string;
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>; mailboxGroups: MailboxGroup[];
generalId: number;
generalName: string;
nationId: number;
canRespondDiplomacy: boolean; canRespondDiplomacy: boolean;
}>(); }>();
@@ -34,213 +65,390 @@ const emit = defineEmits<{
(event: 'refresh'): void; (event: 'refresh'): void;
(event: 'load-older', type: MessageType): void; (event: 'load-older', type: MessageType): void;
(event: 'respond', messageId: number, response: boolean): void; (event: 'respond', messageId: number, response: boolean): void;
(event: 'read-latest', type: 'private' | 'diplomacy', messageId: number): void;
(event: 'delete', messageId: number): void;
}>(); }>();
const messageTabs: Array<{ key: MessageType; label: string }> = [ const sections: Array<{ type: MessageType; label: string; className: string }> = [
{ key: 'public', label: '전체' }, { type: 'public', label: '전체 메시지', className: 'PublicTalk' },
{ key: 'national', label: '국가' }, { type: 'national', label: '국가 메시지', className: 'NationalTalk' },
{ key: 'private', label: '개인' }, { type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
{ key: 'diplomacy', label: '외교' }, { type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
]; ];
const activeTab = ref<MessageType>('public'); const visibleLimits = reactive<Record<MessageType, number>>({
public: Number.POSITIVE_INFINITY,
const activeMessages = computed(() => { national: Number.POSITIVE_INFINITY,
if (!props.messages) { private: Number.POSITIVE_INFINITY,
return [] as MessageEntry[]; diplomacy: Number.POSITIVE_INFINITY,
}
return props.messages[activeTab.value] ?? [];
}); });
const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ?? [];
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
const permission = computed(() => props.messages?.permission ?? -1);
const setMailbox = (value: string) => { const setMailbox = (value: string) => {
const parsed = Number(value); const parsed = Number(value);
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0); emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
}; };
const isDiplomacyPrompt = (message: MessageEntry): boolean => const submit = () => {
message.msgType === 'diplomacy' && if (!props.draftText.trim()) {
(message.option?.action === 'noAggression' || emit('refresh');
message.option?.action === 'cancelNA' ||
message.option?.action === 'stopWar');
const respond = (messageId: number, response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
return; return;
} }
emit('send');
};
const newestIncomingId = (type: 'private' | 'diplomacy'): number =>
bucket(type)
.filter((message) => message.src.generalId !== props.generalId)
.reduce((latest, message) => Math.max(latest, message.id), 0);
const canMarkRead = (type: 'private' | 'diplomacy'): boolean => {
if (!props.messages) {
return false;
}
const newest = newestIncomingId(type);
return newest > props.messages.latestRead[type];
};
const markRead = (type: 'private' | 'diplomacy') => {
const messageId = newestIncomingId(type);
if (messageId > 0) {
emit('read-latest', type, messageId);
}
};
const setSectionMailbox = (type: MessageType) => {
if (type === 'public') {
emit('update:targetMailbox', 9999);
} else if (type === 'national') {
emit('update:targetMailbox', 9000 + props.nationId);
}
};
const setReplyTarget = (type: MessageType, target: MessageTarget) => {
const mailbox =
(type === 'diplomacy' || type === 'national') && target.nationId !== props.nationId
? 9000 + target.nationId
: target.generalId;
if (mailbox > 0) {
emit('update:targetMailbox', mailbox);
}
};
const fold = (type: MessageType) => {
if (bucket(type).length >= 10) {
visibleLimits[type] = 10;
}
};
const forwardResponse = (messageId: number, response: boolean) => {
emit('respond', messageId, response); emit('respond', messageId, response);
}; };
</script> </script>
<template> <template>
<div class="message-panel"> <div class="MessagePanel">
<div class="message-input"> <div class="MessageInputForm">
<select <div id="mailbox_list-col">
class="message-select" <select
:value="targetMailbox" id="mailbox_list"
@change="setMailbox(($event.target as HTMLSelectElement).value)" class="message-select"
> :value="targetMailbox"
<option aria-label="메시지 수신 대상"
v-for="option in mailboxOptions" @change="setMailbox(($event.target as HTMLSelectElement).value)"
:key="option.label"
:value="option.value"
:disabled="option.disabled"
> >
{{ option.label }} <optgroup
</option> v-for="group in mailboxGroups"
</select> :key="group.label"
<input :label="group.label"
class="message-text" :style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
type="text" >
maxlength="99" <option
:value="draftText" v-for="option in group.options"
placeholder="메시지 입력" :key="`${group.label}-${option.value}`"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)" :value="option.value"
@keydown.enter="emit('send')" :disabled="option.disabled"
/> :style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
<button class="message-send" @click="emit('send')">전송</button> >
</div> {{ option.label }}
</option>
<div class="message-tabs"> </optgroup>
<button </select>
v-for="tab in messageTabs" </div>
:key="tab.key" <div id="msg_input-col">
:class="{ active: activeTab === tab.key }" <input
@click="activeTab = tab.key" class="message-text"
> type="text"
{{ tab.label }} maxlength="99"
</button> :value="draftText"
<button class="refresh" @click="emit('refresh')">갱신</button> aria-label="메시지 입력"
</div> @input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="submit"
<div v-if="props.loading"> />
<SkeletonLines :lines="4" /> </div>
</div> <div id="msg_submit-col">
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div> <button class="message-send" type="button" @click="submit">서신전달&amp;갱신</button>
<div v-else class="message-list">
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
<div v-else>
<div v-for="message in activeMessages" :key="message.id" class="message-item">
<div class="text">{{ message.text }}</div>
<div v-if="isDiplomacyPrompt(message)" class="message-response">
<button class="accept" :disabled="!canRespondDiplomacy" @click="respond(message.id, true)">
수락
</button>
<button class="decline" :disabled="!canRespondDiplomacy" @click="respond(message.id, false)">
거절
</button>
</div>
<div class="time">{{ message.time }}</div>
</div>
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
</div> </div>
</div> </div>
<div v-if="loading && !messages" class="message-loading">
<SkeletonLines :lines="4" />
</div>
<template v-else>
<section
v-for="section in sections"
:key="section.type"
:class="['message-section', section.className]"
:data-message-type="section.type"
>
<div class="stickyAnchor"></div>
<header class="BoardHeader">
<div class="header-label">{{ section.label }}</div>
<button
v-if="section.type === 'public' || section.type === 'national'"
class="btn-more-small action-primary"
type="button"
@click="setSectionMailbox(section.type)"
>
여기로
</button>
<button
v-else
class="btn-more-small action-secondary"
type="button"
:disabled="!canMarkRead(section.type)"
@click="markRead(section.type)"
>
모두 읽음
</button>
</header>
<div v-if="bucket(section.type).length === 0" class="empty-message">메시지가 없습니다.</div>
<div v-else class="MessageList">
<MessagePlate
v-for="message in visibleMessages(section.type)"
:key="message.id"
:message="message"
:general-id="generalId"
:general-name="generalName"
:nation-id="nationId"
:permission="permission"
:can-respond-diplomacy="canRespondDiplomacy"
@set-target="setReplyTarget"
@delete="emit('delete', $event)"
@respond="forwardResponse"
/>
<div class="Actions">
<button class="fold-message" type="button" @click="fold(section.type)">접기</button>
<button class="load-older" type="button" @click="emit('load-older', section.type)">
이전 메시지 불러오기
</button>
</div>
</div>
</section>
</template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.message-panel { .MessagePanel {
display: flex; color: #fff;
flex-direction: column; font-size: 14px;
gap: 12px;
} }
.message-input { .MessageInputForm {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr) minmax(0, 1fr);
grid-template-areas: 'mailbox input submit';
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
#mailbox_list-col {
grid-area: mailbox;
}
#msg_input-col {
grid-area: input;
}
#msg_submit-col {
grid-area: submit;
}
#mailbox_list-col,
#msg_input-col,
#msg_submit-col {
display: grid; display: grid;
grid-template-columns: minmax(90px, 120px) 1fr auto;
gap: 6px;
} }
.message-select, .message-select,
.message-text,
.message-send {
height: 35.5px;
border: 1px solid #6c757d;
border-radius: 4px;
font: inherit;
}
.message-select {
width: 100%;
background-color: #212529;
padding: 4px 30px 4px 12px;
color: #fff;
font-weight: 700;
}
.message-text { .message-text {
background: rgba(16, 16, 16, 0.8); width: 100%;
border: 1px solid rgba(201, 164, 90, 0.4); background-color: #fff;
color: inherit; padding: 4px 8px;
padding: 6px; color: #212529;
font-size: 0.75rem; }
.message-send,
.action-primary {
background-color: #337ab7;
color: #fff;
} }
.message-send { .message-send {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 10px;
font-size: 0.75rem;
cursor: pointer;
}
.message-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.message-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px; padding: 4px 8px;
font-size: 0.7rem;
cursor: pointer; cursor: pointer;
} }
.message-tabs button.active { .message-send:hover {
background: rgba(201, 164, 90, 0.2); background-color: #375a7f;
} }
.message-tabs .refresh { .message-send:focus,
margin-left: auto; .message-send:focus-visible {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
} }
.message-list { .message-loading {
padding: 8px;
}
.message-section {
min-width: 0;
}
.BoardHeader {
display: flex; display: flex;
flex-direction: column; min-height: 25px;
gap: 8px; align-items: center;
outline: 1px solid gray;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
color: #fff;
} }
.message-item { .header-label {
border: 1px solid rgba(201, 164, 90, 0.2); flex: 1;
padding: 6px;
font-size: 0.75rem;
} }
.message-item .time { .btn-more-small {
margin-top: 4px; margin: 1px;
font-size: 0.65rem; border: 1px solid transparent;
color: rgba(232, 221, 196, 0.6); border-radius: 3px;
} padding: 2px 6px;
font-size: 11.2px;
.message-response { line-height: 1.5;
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 3px 10px;
font-size: 0.7rem;
cursor: pointer; cursor: pointer;
} }
.message-response .accept { .action-secondary {
color: #8fd18f; border-color: #6c757d;
background-color: #6c757d;
color: #fff;
} }
.message-response .decline { .btn-more-small:disabled {
color: #e09a9a; cursor: default;
opacity: 0.65;
} }
.message-response button:disabled { .empty-message {
cursor: not-allowed; min-height: 22px;
opacity: 0.5; }
.MessageList {
overflow-x: hidden;
}
.Actions {
display: grid;
}
.fold-message,
.load-older {
border: 1px solid transparent;
padding: 6px 12px;
color: #fff;
font: inherit;
cursor: pointer;
}
.fold-message {
background-color: #212529;
} }
.load-older { .load-older {
border: 1px dashed rgba(201, 164, 90, 0.3); background-color: #6c757d;
padding: 6px;
font-size: 0.7rem;
cursor: pointer;
} }
.empty { @media (min-width: 940px) {
color: rgba(232, 221, 196, 0.6); .MessagePanel {
display: grid;
grid-template-columns: 1fr 1fr;
}
.MessageInputForm,
.message-loading {
grid-column: 1 / 3;
}
.PublicTalk,
.PrivateTalk {
border-right: 1px solid gray;
}
.fold-message {
display: none;
}
.MessageList {
overflow-y: auto;
}
}
@media (max-width: 939.98px) {
.MessageInputForm {
position: sticky;
z-index: 5;
top: 0;
grid-template-columns: 1fr 1fr;
grid-template-areas:
'mailbox submit'
'input input';
}
.message-text {
height: 33.5px;
}
.BoardHeader {
position: sticky;
z-index: 4;
top: 62px;
}
} }
</style> </style>
@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { MessageType } from '@sammo-ts/logic';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
const props = defineProps<{
message: MessageEntry;
generalId: number;
generalName: string;
nationId: number;
permission: number;
canRespondDiplomacy: boolean;
}>();
const emit = defineEmits<{
(event: 'set-target', type: MessageType, target: MessageTarget): void;
(event: 'delete', messageId: number): void;
(event: 'respond', messageId: number, response: boolean): void;
}>();
const now = ref(Date.now());
let deleteTimer: number | null = null;
const destination = computed<MessageTarget>(
() =>
props.message.dest ?? {
generalId: 0,
generalName: '',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '/image/icons/default.jpg',
}
);
const invalid = computed(() => props.message.option?.invalid === true);
const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) {
return 'local';
}
return props.message.src.nationId === props.nationId ? 'src' : 'dest';
});
const parseMessageTime = (): number => {
const normalized = props.message.time.includes('T')
? props.message.time
: `${props.message.time.replace(' ', 'T')}Z`;
return Date.parse(normalized);
};
const deletable = computed(() => {
if (invalid.value || hasAction.value || props.message.src.generalId !== props.generalId) {
return false;
}
if (props.message.option?.deletable === false) {
return false;
}
const sentAt = parseMessageTime();
return Number.isFinite(sentAt) && sentAt + 5 * 60 * 1000 > now.value;
});
const scheduleDeleteExpiry = () => {
const sentAt = parseMessageTime();
if (!Number.isFinite(sentAt)) {
return;
}
const delay = sentAt + 5 * 60 * 1000 - Date.now();
if (delay <= 0) {
now.value = Date.now();
return;
}
deleteTimer = window.setTimeout(() => {
now.value = Date.now();
}, delay);
};
const isBright = (color: string): boolean => {
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) {
return false;
}
const value = Number.parseInt(match[1]!, 16);
const red = (value >> 16) & 0xff;
const green = (value >> 8) & 0xff;
const blue = value & 0xff;
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
};
const iconUrl = computed(() => {
const icon = props.message.src.icon?.trim();
if (!icon) {
return '/image/icons/default.jpg';
}
if (icon.startsWith('/') || /^https?:\/\//i.test(icon)) {
return icon;
}
return `${import.meta.env.BASE_URL}${icon.replace(/^\/+/, '')}`;
});
const targetClass = (target: MessageTarget) => ({
'msg-target': true,
'msg-bright': isBright(target.color),
'msg-dark': !isBright(target.color),
});
const setTarget = (target: MessageTarget) => {
emit('set-target', props.message.msgType, target);
};
const requestDelete = () => {
if (!window.confirm('삭제하시겠습니까?')) {
return;
}
emit('delete', props.message.id);
};
const respond = (response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
return;
}
emit('respond', props.message.id, response);
};
onMounted(scheduleDeleteExpiry);
onBeforeUnmount(() => {
if (deleteTimer !== null) {
window.clearTimeout(deleteTimer);
}
});
</script>
<template>
<article
:id="`msg_${message.id}`"
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
:data-id="message.id"
>
<div class="msg-icon">
<img class="general-icon" width="64" height="64" :src="iconUrl" :alt="message.src.generalName" />
</div>
<div class="msg-body">
<div class="msg-header">
<button v-if="deletable" class="delete-message" type="button" @click="requestDelete"></button>
<template v-if="message.msgType === 'private'">
<template v-if="message.src.generalId === generalId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"
></span
>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.generalName }}:{{ destination.nationName }} |
</button>
</template>
<template v-else>
<button
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
></span
>
</template>
</template>
<template v-else-if="message.msgType === 'national' && message.src.nationId === destination.nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
</template>
<template
v-else-if="(message.msgType === 'national' || message.msgType === 'diplomacy') && permission >= 4"
>
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.nationName }} |
</button>
</template>
<button
v-else
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
</template>
<template v-else-if="message.msgType === 'national' || message.msgType === 'diplomacy'">
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
{{ destination.nationName }}
</span>
</template>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
</template>
<button
v-else-if="message.src.generalId !== generalId"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-time">&lt;{{ message.time }}&gt;</span>
</div>
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
<div v-if="hasAction" class="message-response">
<button
class="prompt-yes"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(true)"
>
수락
</button>
<button
class="prompt-no"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(false)"
>
거절
</button>
</div>
</div>
</article>
</template>
<style scoped>
.msg-plate {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
width: 100%;
min-height: 64px;
outline: 1px solid gray;
color: #fff;
font-size: 12.5px;
word-break: break-all;
}
.msg-plate-private {
background-color: #5d1e1a;
}
.msg-plate-private.msg-plate-dest {
background-color: #5d461a;
}
.msg-plate-public {
background-color: #141c65;
}
.msg-plate-national,
.msg-plate-diplomacy {
background-color: #00582c;
}
.msg-plate-national.msg-plate-dest,
.msg-plate-diplomacy.msg-plate-dest {
background-color: #704615;
}
.msg-plate-national.msg-plate-src,
.msg-plate-diplomacy.msg-plate-src {
background-color: #70153b;
}
.msg-icon {
width: 64px;
height: 64px;
border-right: 1px solid gray;
}
.general-icon {
display: block;
width: 64px;
max-width: none;
height: 64px;
object-fit: fill;
}
.msg-body {
min-width: 0;
padding-left: 0;
}
.msg-header {
position: relative;
margin-bottom: 3px;
color: #fff;
font-weight: 700;
}
.msg-target {
display: inline-block;
margin: 2px 2px 0;
border: 0;
border-radius: 3px;
padding: 2px 3px;
box-shadow: 2px 2px #000;
font: inherit;
font-weight: inherit;
}
button.msg-target {
cursor: pointer;
}
.msg-bright {
color: #000;
}
.msg-dark {
color: #fff;
}
.msg-from-to {
display: inline-block;
}
.msg-time {
font-size: 0.75em;
font-weight: 400;
}
.delete-message {
position: absolute;
z-index: 1;
top: 0;
right: 0;
margin: 2px 2px 0;
border: 1px solid #ffc107;
border-radius: 3px;
background: transparent;
padding: 2px 4px;
color: #ffc107;
font-size: 8px;
cursor: pointer;
}
.msg-content {
overflow: hidden;
margin-right: 5px;
margin-left: 10px;
white-space: pre-wrap;
}
.msg-invalid {
color: rgba(255, 255, 255, 0.5);
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 0;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
min-width: 42px;
border: 1px outset buttonborder;
background: buttonface;
padding: 1px 6px;
color: buttontext;
font-size: 12.5px;
cursor: pointer;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
</style>
+14 -4
View File
@@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue';
import GlobalInfoView from '../views/GlobalInfoView.vue'; import GlobalInfoView from '../views/GlobalInfoView.vue';
import CurrentCityView from '../views/CurrentCityView.vue'; import CurrentCityView from '../views/CurrentCityView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationSecretView from '../views/NationSecretView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue';
import NationStratFinanView from '../views/NationStratFinanView.vue'; import NationStratFinanView from '../views/NationStratFinanView.vue';
import ChiefCenterView from '../views/ChiefCenterView.vue'; import ChiefCenterView from '../views/ChiefCenterView.vue';
@@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue'; import TournamentView from '../views/TournamentView.vue';
import BettingView from '../views/BettingView.vue'; import BettingView from '../views/BettingView.vue';
import MyPageView from '../views/MyPageView.vue'; import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import BoardView from '../views/BoardView.vue'; import BoardView from '../views/BoardView.vue';
import DiplomacyView from '../views/DiplomacyView.vue'; import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue'; import BestGeneralView from '../views/BestGeneralView.vue';
@@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue';
import YearbookView from '../views/YearbookView.vue'; import YearbookView from '../views/YearbookView.vue';
import NationBettingView from '../views/NationBettingView.vue'; import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue'; import NpcListView from '../views/NpcListView.vue';
import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
const routes = [ const routes = [
@@ -148,6 +149,12 @@ const routes = [
requiresGeneral: true, requiresGeneral: true,
}, },
}, },
{
path: '/nation/secret',
name: 'nation-secret',
component: NationSecretView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{ {
path: '/nation/personnel', path: '/nation/personnel',
name: 'nation-personnel', name: 'nation-personnel',
@@ -190,7 +197,6 @@ const routes = [
component: BattleSimulatorView, component: BattleSimulatorView,
meta: { meta: {
requiresAuth: true, requiresAuth: true,
requiresGeneral: true,
}, },
}, },
{ {
@@ -252,6 +258,11 @@ const routes = [
requiresGeneral: true, requiresGeneral: true,
}, },
}, },
{
path: '/traffic',
name: 'traffic',
component: TrafficView,
},
{ {
path: '/npc-list', path: '/npc-list',
name: 'npc-list', name: 'npc-list',
@@ -277,8 +288,7 @@ const routes = [
}, },
{ {
path: '/my-settings', path: '/my-settings',
name: 'my-settings', redirect: '/my-page',
component: MySettingsView,
meta: { meta: {
requiresAuth: true, requiresAuth: true,
requiresGeneral: true, requiresGeneral: true,
+140 -22
View File
@@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>; type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>; type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>; type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>; type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number]; type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
@@ -37,12 +38,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const mapLayout = ref<MapLayout | null>(null); const mapLayout = ref<MapLayout | null>(null);
const commandTable = ref<CommandTable | null>(null); const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null); const messages = ref<MessageBundle | null>(null);
const messageContacts = ref<MessageContacts | null>(null);
const boardAccess = ref<BoardAccess | null>(null); const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null); const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null); const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
const messageDraftText = ref(''); const messageDraftText = ref('');
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC); const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
let initializedMailboxGeneralId: number | null = null;
const general = computed(() => generalContext.value?.general ?? null); const general = computed(() => generalContext.value?.general ?? null);
const city = computed(() => generalContext.value?.city ?? null); const city = computed(() => generalContext.value?.city ?? null);
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} as const; } as const;
}); });
const mailboxOptions = computed(() => { const mailboxGroups = computed(() => {
const options: Array<{ label: string; value: number; disabled?: boolean }> = [ type MailboxOption = {
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC }, label: string;
value: number;
disabled?: boolean;
color?: string;
};
type MailboxGroup = {
label: string;
color?: string;
options: MailboxOption[];
};
const ownNationId = general.value?.nationId ?? 0;
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
const permission = messages.value?.permission ?? -1;
const contacts = messageContacts.value?.nation ?? [];
const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox);
const groups: MailboxGroup[] = [
{
label: '즐겨찾기',
color: '#000000',
options: [
{
label: '【 아국 메세지 】',
value: ownMailbox,
color: ownNation?.color ?? '#000000',
},
{
label: '【 전체 메세지 】',
value: MESSAGE_MAILBOX_PUBLIC,
color: '#000000',
},
],
},
]; ];
if (nationId.value) {
options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value }); if (permission >= 4) {
} else { groups.push({
options.push({ label: '국가', value: -1, disabled: true }); label: '외교메시지',
color: '#000000',
options: contacts
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
.map((nation) => ({
label: nation.name,
value: nation.mailbox,
color: nation.color,
})),
});
} }
options.push({ label: '외교', value: -2, disabled: true });
options.push({ label: '개인', value: -3, disabled: true }); const sortedContacts = [...contacts].sort((left, right) => {
return options; if (left.mailbox === ownMailbox) return -1;
if (right.mailbox === ownMailbox) return 1;
return left.mailbox - right.mailbox;
});
for (const nation of sortedContacts) {
const options = [...nation.general]
.filter(([id]) => id !== generalId.value)
.sort((left, right) => left[1].localeCompare(right[1], 'ko'))
.map(([id, name, flags]) => {
const ruler = Boolean(flags & 1);
const ambassador = Boolean(flags & 4);
return {
label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name,
value: id,
disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox,
color: nation.color,
};
});
if (options.length > 0) {
groups.push({
label: nation.name,
color: nation.color,
options,
});
}
}
return groups;
}); });
const statusLine = computed(() => { const statusLine = computed(() => {
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
context.general.nationId > 0 && context.general.officerLevel >= 5 context.general.nationId > 0 && context.general.officerLevel >= 5
? trpc.turns.reserved.getNation.query({ generalId: id }) ? trpc.turns.reserved.getNation.query({ generalId: id })
: Promise.resolve(null); : Promise.resolve(null);
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([ const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
layoutPromise, await Promise.all([
trpc.lobby.info.query(), layoutPromise,
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), trpc.lobby.info.query(),
trpc.turns.getCommandTable.query({ generalId: id }), trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.messages.getRecent.query({ generalId: id }), trpc.turns.getCommandTable.query({ generalId: id }),
trpc.board.getAccess.query(), trpc.messages.getRecent.query({ generalId: id }),
generalTurnsPromise, trpc.messages.getContacts.query({ generalId: id }),
nationTurnsPromise, trpc.board.getAccess.query(),
]); generalTurnsPromise,
nationTurnsPromise,
]);
mapLayout.value = layout; mapLayout.value = layout;
lobbyInfo.value = lobby; lobbyInfo.value = lobby;
worldMap.value = map; worldMap.value = map;
commandTable.value = commands; commandTable.value = commands;
messages.value = messageData; messages.value = messageData;
messageContacts.value = contacts;
boardAccess.value = access; boardAccess.value = access;
reservedGeneralTurns.value = generalTurns; reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns; reservedNationTurns.value = nationTurns;
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
}
} catch (err) { } catch (err) {
error.value = resolveErrorMessage(err); error.value = resolveErrorMessage(err);
} finally { } finally {
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} }
try { try {
messageDraftText.value = '';
await trpc.messages.send.mutate({ await trpc.messages.send.mutate({
generalId: id, generalId: id,
mailbox, mailbox,
text, text,
}); });
messageDraftText.value = '';
await refreshMessages(); await refreshMessages();
} catch (err) { } catch (err) {
error.value = resolveErrorMessage(err); error.value = resolveErrorMessage(err);
@@ -260,6 +337,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} }
}; };
const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => {
const id = generalId.value;
if (!id || messageId <= 0) {
return;
}
try {
await trpc.messages.readLatest.mutate({
generalId: id,
type,
messageId,
});
if (messages.value) {
messages.value = {
...messages.value,
latestRead: {
...messages.value.latestRead,
[type]: Math.max(messages.value.latestRead[type], messageId),
},
};
}
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const deleteMessage = async (messageId: number) => {
const id = generalId.value;
if (!id) {
return;
}
try {
await trpc.messages.delete.mutate({ generalId: id, messageId });
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const setGeneralTurn = async (turnIndex: number, action: string) => { const setGeneralTurn = async (turnIndex: number, action: string) => {
const id = generalId.value; const id = generalId.value;
if (!id) { if (!id) {
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
selectedCity, selectedCity,
commandTable, commandTable,
messages, messages,
messageContacts,
boardAccess, boardAccess,
reservedGeneralTurns, reservedGeneralTurns,
reservedNationTurns, reservedNationTurns,
messageDraftText, messageDraftText,
targetMailbox, targetMailbox,
mailboxOptions, mailboxGroups,
statusLine, statusLine,
realtimeLabel, realtimeLabel,
setRealtimeEnabled, setRealtimeEnabled,
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
sendMessage, sendMessage,
loadOlderMessages, loadOlderMessages,
respondToMessage, respondToMessage,
readLatestMessage,
deleteMessage,
setGeneralTurn, setGeneralTurn,
shiftGeneralTurns, shiftGeneralTurns,
setNationTurn, setNationTurn,
+180 -56
View File
@@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } 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 GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
@@ -269,7 +268,26 @@ onMounted(() => {
</PanelCard> </PanelCard>
<PanelCard title="장수 정보"> <PanelCard title="장수 정보">
<GeneralBasicCard :general="selectedGeneral" :loading="loading" /> <SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
</div>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
</div>
<div v-if="selectedGeneral" class="general-meta"> <div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div> <div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div> <div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -286,12 +304,7 @@ onMounted(() => {
<SkeletonLines v-if="loading || logLoading" :lines="3" /> <SkeletonLines v-if="loading || logLoading" :lines="3" />
<template v-else> <template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div> <div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div <div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
v-for="entry in logs[type]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
</template> </template>
</div> </div>
</div> </div>
@@ -303,126 +316,237 @@ onMounted(() => {
<style scoped> <style scoped>
.battle-page { .battle-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
min-height: 100vh; min-height: 100vh;
padding: 24px; margin: 0 auto;
padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 0;
color: #fff;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.5;
} }
.page-header { .page-header {
position: relative;
min-height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 16px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 0 8px;
border: 1px solid #666;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
} }
.page-title { .page-title {
font-size: 1.6rem; font-size: 17px;
font-weight: 700; font-weight: 500;
} }
.page-subtitle { .page-subtitle {
color: rgba(232, 221, 196, 0.7); display: none;
margin-top: 6px;
} }
.header-actions { .header-actions {
position: absolute;
left: 0;
top: 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 4px;
} }
.layout-grid { .layout-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px; gap: 0;
} }
.stack { .stack {
display: flex; display: contents;
flex-direction: column;
gap: 18px;
} }
.selector-row { .selector-row {
display: grid; display: grid;
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto; grid-template-columns: 8.333% 33.333% 50% 8.333%;
gap: 8px; gap: 0;
align-items: center; align-items: center;
} }
.select-input { .select-input {
min-width: 0; min-width: 0;
padding: 6px 8px; height: 36px;
border: 1px solid rgba(201, 164, 90, 0.4); padding: 4px 6px;
background: rgba(12, 12, 12, 0.7); border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit; color: inherit;
font-size: 0.85rem; font: inherit;
} }
.ghost { .ghost {
border: 1px solid rgba(201, 164, 90, 0.5); min-height: 32px;
background: transparent; border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit; color: inherit;
padding: 6px 10px; padding: 4px 8px;
font-size: 0.8rem; font: inherit;
cursor: pointer; cursor: pointer;
} }
.general-meta { .general-meta {
margin-top: 10px; margin: 0;
font-size: 0.85rem; padding: 6px 8px;
color: rgba(232, 221, 196, 0.75); color: #ccc;
display: grid; display: grid;
gap: 4px; gap: 4px;
} }
.log-grid { .battle-general-card {
min-height: 292px;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid #777;
background: rgba(220, 220, 220, 0.85);
color: #111;
font-weight: 700;
}
.battle-general-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(6, 1fr);
gap: 12px; }
.battle-general-grid > * {
min-height: 24px;
padding: 2px 5px;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
}
.battle-general-grid > span {
background-color: rgba(20, 75, 42, 0.7);
color: #fff;
text-align: center;
}
.battle-general-grid > strong {
text-align: right;
font-weight: 500;
}
.log-grid {
display: contents;
} }
.log-block { .log-block {
border: 1px solid rgba(201, 164, 90, 0.3); border: 1px solid #666;
padding: 8px; padding: 0;
background: rgba(12, 12, 12, 0.6); background: #111;
min-height: 160px; min-height: 180px;
} }
.log-title { .log-title {
font-weight: 600; min-height: 34px;
margin-bottom: 6px; margin: 0;
font-size: 0.9rem; display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
color: orange;
background: #252525;
font-size: 1.3em;
font-weight: 500;
} }
.log-line { .log-line {
padding: 4px 0; padding: 2px 8px;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2); border-bottom: 0;
}
.log-line:last-child {
border-bottom: none;
} }
.empty { .empty {
color: rgba(232, 221, 196, 0.6); padding: 2px 8px;
font-size: 0.85rem; color: #999;
} }
.error { .error {
color: #f08a5d; padding: 5px 8px;
font-size: 0.9rem; color: #ff7777;
border: 1px solid #a33;
text-align: center;
} }
@media (max-width: 1024px) { /* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */
:deep(.panel-card) {
height: 100%;
border: 1px solid #666;
border-radius: 0;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
box-shadow: none;
}
.stack:first-child :deep(.panel-card:first-child) {
grid-column: 1 / -1;
border: 0;
}
.stack:first-child :deep(.panel-card:first-child .panel-header) {
display: none;
}
.stack:first-child :deep(.panel-card:first-child .panel-body) {
padding: 0;
}
.stack:nth-child(2) :deep(.panel-card),
.stack:nth-child(2) :deep(.panel-body) {
display: contents;
}
.stack:nth-child(2) :deep(.panel-header) {
display: none;
}
:deep(.panel-header) {
min-height: 29px;
justify-content: center;
padding: 0;
}
:deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
:deep(.panel-header),
.log-title {
background-image: url('/image/game/back_green.jpg');
}
@media (max-width: 991px) {
.battle-page {
width: 500px;
}
.layout-grid { .layout-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.selector-row { .selector-row {
grid-template-columns: 16.666% 25% 41.666% 16.666%;
}
.log-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -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>
+292 -61
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type RankEntry = { type RankEntry = {
@@ -11,7 +13,6 @@ type RankEntry = {
fgColor: string; fgColor: string;
picture: string | null; picture: string | null;
imageServer: number; imageServer: number;
value: number;
printValue: string; printValue: string;
}; };
@@ -21,18 +22,17 @@ type RankSection = {
entries: RankEntry[]; entries: RankEntry[];
}; };
type UniqueOwner = { type UniqueItemEntry = {
id: number; itemKey: string;
name: string; itemName: string;
nationName: string; itemInfo: string;
bgColor: string; owner: Omit<RankEntry, 'ownerName' | 'printValue'>;
fgColor: string;
}; };
type UniqueItemSection = { type UniqueItemSection = {
title: string; title: string;
slot: string; slot: string;
owners: UniqueOwner[]; entries: UniqueItemEntry[];
}; };
type BestGeneralPayload = { type BestGeneralPayload = {
@@ -41,12 +41,26 @@ type BestGeneralPayload = {
uniqueItems: UniqueItemSection[]; uniqueItems: UniqueItemSection[];
}; };
const router = useRouter();
const viewMode = ref<'user' | 'npc'>('user'); const viewMode = ref<'user' | 'npc'>('user');
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const data = ref<BestGeneralPayload | null>(null); const data = ref<BestGeneralPayload | null>(null);
const refresh = async () => { const imageUrl = (entry: { picture: string | null; imageServer: number }): string => {
const picture = entry.picture?.trim() || 'default.jpg';
return entry.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const refresh = async (): Promise<void> => {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
@@ -58,8 +72,6 @@ const refresh = async () => {
} }
}; };
const emptyLabel = computed(() => (loading.value ? '불러오는 중...' : '표시할 데이터가 없습니다.'));
onMounted(() => { onMounted(() => {
void refresh(); void refresh();
}); });
@@ -70,61 +82,280 @@ watch(viewMode, () => {
</script> </script>
<template> <template>
<main class="main-page"> <main id="best-general-container" class="legacy-ranking-page legacy-bg0">
<header class="page-header"> <div class="legacy-ranking-title">
<div> <br />
<h1 class="page-title">명장일람</h1> <button class="legacy-button" type="button" @click="closePage"> 닫기</button>
<p class="page-subtitle">전장 기록을 기준으로 장수 순위를 확인합니다.</p> </div>
</div>
<div class="header-actions">
<button class="ghost" :class="{ active: viewMode === 'user' }" @click="viewMode = 'user'">
유저 보기
</button>
<button class="ghost" :class="{ active: viewMode === 'npc' }" @click="viewMode = 'npc'">
NPC 보기
</button>
<button class="ghost" @click="refresh">새로고침</button>
</div>
</header>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div> <div class="view-selector" role="group" aria-label="장수 유형">
<div v-else-if="!data" class="placeholder">{{ emptyLabel }}</div> <button
class="legacy-button"
type="button"
:aria-pressed="viewMode === 'user'"
@click="viewMode = 'user'"
>
유저 보기
</button>
<button
class="legacy-button"
type="button"
:aria-pressed="viewMode === 'npc'"
@click="viewMode = 'npc'"
>
NPC 보기
</button>
</div>
<section v-if="data" class="grid gap-4"> <div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4"> <div v-else-if="loading && !data" class="legacy-message">불러오는 중...</div>
<h2 class="text-base font-semibold mb-3">{{ section.title }}</h2>
<div v-if="section.entries.length === 0" class="text-xs text-zinc-500">{{ emptyLabel }}</div> <section v-if="data" class="ranking-sections" :aria-busy="loading">
<ul v-else class="space-y-2"> <article v-for="section in data.sections" :key="section.title" class="rankView legacy-bg0">
<li <h2 class="rankType legacy-bg1">{{ section.title }}</h2>
v-for="entry in section.entries" <ul>
:key="entry.id" <li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.id}:${rank}`">
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm" <div class="hall-rank legacy-bg2">{{ rank + 1 }}</div>
> <div class="hall-img">
<div class="flex items-center gap-2"> <img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: entry.bgColor }" />
<span class="font-semibold">{{ entry.name }}</span>
<span class="text-xs text-zinc-400">{{ entry.nationName }}</span>
</div> </div>
<div class="text-xs text-zinc-200">{{ entry.printValue }}</div> <div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
{{ entry.nationName || '-' }}
</div>
<div class="hall-name" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<span>{{ entry.name || '-' }}</span>
<small v-if="entry.ownerName">({{ entry.ownerName }})</small>
</div>
<div class="hall-value">{{ entry.printValue }}</div>
</li> </li>
</ul> </ul>
</div> </article>
<article v-for="section in data.uniqueItems" :key="section.slot" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li
v-for="(entry, index) in section.entries"
:key="`${entry.itemKey}:${index}`"
class="no-value"
>
<div class="hall-rank legacy-bg2 item-name" :title="entry.itemInfo">{{ entry.itemName }}</div>
<div class="hall-img">
<img
class="generalIcon"
:src="imageUrl(entry.owner)"
width="64"
height="64"
:alt="entry.owner.name"
/>
</div>
<div
class="hall-nation"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
{{ entry.owner.nationName || '-' }}
</div>
<div
class="hall-name"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
<span>{{ entry.owner.name || '-' }}</span>
</div>
</li>
</ul>
</article>
</section> </section>
<section v-if="data" class="mt-6 bg-zinc-900 border border-zinc-800 rounded p-4"> <div class="legacy-ranking-bottom">
<h2 class="text-base font-semibold mb-3">유니크 아이템 소유자</h2> <button class="legacy-button" type="button" @click="closePage"> 닫기</button>
<div class="grid md:grid-cols-2 gap-4"> </div>
<div v-for="item in data.uniqueItems" :key="item.title" class="bg-zinc-950 border border-zinc-800 rounded p-3"> <footer class="legacy-banner">
<h3 class="text-sm font-semibold mb-2">{{ item.title }}</h3> 삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<ul class="space-y-1 text-xs"> <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
<li v-for="owner in item.owners" :key="owner.id" class="flex items-center gap-2"> </footer>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: owner.bgColor }" />
<span>{{ owner.name }}</span>
<span class="text-zinc-500">{{ owner.nationName }}</span>
</li>
</ul>
</div>
</div>
</section>
</main> </main>
</template> </template>
<style scoped>
:global(body) {
min-width: 500px;
overflow-x: hidden;
}
.legacy-ranking-page {
width: 500px;
min-height: 100vh;
margin: 0 auto 100px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
}
.legacy-ranking-title,
.legacy-ranking-bottom {
text-align: left;
}
.view-selector {
text-align: center;
}
.legacy-ranking-title {
padding-top: 2px;
}
.view-selector {
padding: 2px 0;
}
.view-selector .legacy-button + .legacy-button {
margin-left: 4px;
}
.view-selector .legacy-button[aria-pressed='true'] {
border-style: inset;
}
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message {
border: 1px solid gray;
padding: 12px;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.ranking-sections {
display: block;
}
.rankView {
position: relative;
margin: auto;
outline: 1px solid gray;
}
.rankType {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
.rankView ul {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
margin: -1px 0;
padding: 0;
list-style: none;
}
.rankView li {
box-sizing: border-box;
flex: 0 0 100px;
width: 100px;
min-height: 149px;
margin: 0;
border-top: 1px solid gray;
border-right: 1px solid gray;
text-align: center;
vertical-align: top;
}
.rankView li.no-value {
min-height: 128px;
}
.hall-rank,
.hall-nation,
.hall-value {
border-bottom: 1px solid gray;
}
.hall-rank.item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-img {
height: 64px;
}
.generalIcon {
display: inline-block;
width: 64px;
height: 64px;
object-fit: fill;
}
.hall-nation,
.hall-name {
font-size: 11px;
}
.hall-name {
display: flex;
height: 28px;
flex-direction: column;
justify-content: center;
}
.hall-name small {
font-size: 95%;
}
.hall-value {
box-sizing: border-box;
padding: 3px 0;
line-height: 13px;
}
@media (min-width: 1000px) {
:global(body) {
min-width: 1000px;
}
.legacy-ranking-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+309 -85
View File
@@ -1,32 +1,110 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat'; import { useRoute, useRouter } from 'vue-router';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>; type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
type General = Result['generals'][number];
const route = useRoute();
const router = useRouter();
const data = ref<Result | null>(null); const data = ref<Result | null>(null);
const error = ref(''); const error = ref('');
const selected = ref<number>(); const selected = ref<number>();
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR')); let loadSequence = 0;
const parseCityId = (): number | undefined => {
const raw = route.query.cityId ?? route.query.citylist;
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
const cityId = Number(value);
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
};
const load = async (cityId?: number) => { const load = async (cityId?: number) => {
const sequence = ++loadSequence;
try { try {
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined); const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
selected.value = data.value.city.id; if (sequence !== loadSequence) return;
data.value = result;
selected.value = result.city.id;
error.value = ''; error.value = '';
} catch (cause) { } catch (cause) {
if (sequence !== loadSequence) return;
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.'; error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
} }
}; };
watch(
() => [route.query.cityId, route.query.citylist],
() => void load(parseCityId()),
{ immediate: true }
);
const selectCity = async () => {
if (!selected.value) return;
await router.push({ name: 'current-city', query: { cityId: selected.value } });
};
const city = computed(() => data.value?.city); const city = computed(() => data.value?.city);
onMounted(() => void load()); const summary = computed(() => data.value?.forceSummary);
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
const populationRate = computed(() => {
if (!city.value || city.value.population === null) return '?';
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
});
const contrastColors = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const cityTitleStyle = computed(() => {
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
return {
backgroundColor,
color: contrastColors.has(backgroundColor) ? '#FFFFFF' : '#000000',
};
});
const woundedStat = (value: number, injury: number) =>
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
const defenceTrainText = (value: number | null) => {
if (value === null) return '?';
if (value === 999) return '×';
if (value >= 90) return '☆';
if (value >= 80) return '◎';
if (value >= 60) return '○';
return '△';
};
const generalImage = (general: General) => {
const picture = general.picture ?? 'default.jpg';
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
</script> </script>
<template> <template>
<main class="city-page"> <main class="city-page">
<table class="legacy-table legacy-bg0 center"> <table class="legacy-table legacy-bg0">
<tbody> <tbody>
<tr> <tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td> <td> <br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -34,32 +112,57 @@ onMounted(() => void load());
<tbody> <tbody>
<tr> <tr>
<td> <td>
도시선택 : <form @submit.prevent="selectCity">
<select v-model.number="selected" @change="load(selected)"> <div>
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id"> 도시선택 :
{{ option.name.padEnd(4, '_') }}{{ <select id="citySelector" v-model.number="selected" @change="selectCity">
option.nationId === data?.me.nationId <option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
? '본국' {{ option.name.padEnd(4, '_') }}{{
: option.nationId === 0 option.nationId === data?.me.nationId
? '공백지' ? '본국'
: '타국' : option.nationId === 0
}} ? '공백지'
</option> : '타국'
</select> }}
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p> </option>
</select>
</div>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
</form>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error" role="alert">{{ error }}</p>
<template v-if="data && city"> <template v-if="data && city">
<table class="legacy-table legacy-bg2 stats"> <table class="legacy-table legacy-bg0 back-row">
<tbody> <tbody>
<tr> <tr>
<td colspan="11" class="city-title"> <td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg2 stats">
<colgroup>
<col class="label-col" />
<col class="first-value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
</colgroup>
<tbody>
<tr>
<td colspan="11" class="city-title" :style="cityTitleStyle">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ city.name }} {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ city.name }}
</td> </td>
<td class="city-title">{{ data.lastExecute }}</td> <td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
</tr> </tr>
<tr> <tr>
<th>주민</th> <th>주민</th>
@@ -79,15 +182,9 @@ onMounted(() => void load());
<th>민심</th> <th>민심</th>
<td>{{ show(city.trust) }}</td> <td>{{ show(city.trust) }}</td>
<th>시세</th> <th>시세</th>
<td>{{ city.trade ?? '-' }}%</td> <td>{{ city.trade ?? '- ' }}%</td>
<th>인구</th> <th>인구</th>
<td> <td>{{ populationRate }}%</td>
{{
city.population === null
? '?'
: ((city.population / city.populationMax) * 100).toFixed(2)
}}%
</td>
<th>태수</th> <th>태수</th>
<td>{{ city.officers[4] }}</td> <td>{{ city.officers[4] }}</td>
<th>군사</th> <th>군사</th>
@@ -95,19 +192,60 @@ onMounted(() => void load());
<th>종사</th> <th>종사</th>
<td>{{ city.officers[2] }}</td> <td>{{ city.officers[2] }}</td>
</tr> </tr>
<tr v-if="summary">
<th>도시명</th>
<td>{{ city.name }}</td>
<th>적군</th>
<td>
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
show(summary.enemyGenerals)
}})
</td>
<th>병장()</th>
<td>
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
show(summary.ownGenerals)
}})
</td>
<th>90병장</th>
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
<th>60병장</th>
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
<th>수비</th>
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
</tr>
<tr> <tr>
<th>장수</th> <th>장수</th>
<td colspan="11"> <td colspan="11" class="general-names">
{{ <template v-if="data.visibility.detailed">
data.visibility.detailed <template v-if="data.generals.length">
? data.generals.map((g) => g.name).join(', ') || '-' <template v-for="(general, index) in data.generals" :key="general.id">
: '알 수 없음' <span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
}} ><template v-if="index < data.generals.length - 1">, </template>
</template>
</template>
<template v-else>-</template>
</template>
<span v-else class="unknown"> 없음</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals"> <table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
<colgroup>
<col style="width: 64px" />
<col style="width: 128px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 78px" />
<col style="width: 28px" />
<col style="width: 78px" />
<col style="width: 78px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 280px" />
</colgroup>
<thead> <thead>
<tr> <tr>
<th> </th> <th> </th>
@@ -125,42 +263,53 @@ onMounted(() => void load());
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="general in data.generals" :key="general.id"> <tr
<td> v-for="general in data.generals"
<img :key="general.id"
v-if="general.picture" :data-is-our-general="general.train !== null"
width="64" :data-general-wounded="general.injury"
height="64" >
:src="`/image/general/${general.picture}`" <td class="icon-cell">
/> <img class="general-icon" width="64" height="64" :src="generalImage(general)" />
</td>
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.leadership, general.injury)
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.strength, general.injury) }}
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.intelligence, general.injury) }}
</td> </td>
<td>{{ general.name }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td> <td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ general.defenceTrain ?? '?' }}</td> <td>{{ defenceTrainText(general.defenceTrain) }}</td>
<td>{{ general.crewTypeId ?? '?' }}</td> <td>{{ general.crewTypeName ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td> <td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td> <td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td> <td>{{ general.atmos ?? '?' }}</td>
<td class="turns"> <td class="turns">
{{ <template v-if="general.turns.length">
general.turns.length <span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ') >{{ index + 1 }} : {{ turn }}</span
: general.npcState > 1 >
? 'NPC 장수' </template>
: `${general.nationName}】 장수` <template v-else-if="general.npcState > 1">NPC 장수</template>
}} <template v-else-if="general.nationId !== data.me.nationId">
{{ general.nationId === 0 ? '재 야' : `${general.nationName}】 장수` }}
</template>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</template> </template>
<table class="legacy-table legacy-bg0 center footer"> <table class="legacy-table legacy-bg0 footer">
<tbody> <tbody>
<tr> <tr>
<td><RouterLink to="/">돌아가기</RouterLink></td> <td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -170,60 +319,138 @@ onMounted(() => void load());
<style scoped> <style scoped>
.city-page { .city-page {
width: 1000px; width: 1000px;
margin: 0 auto; margin: 8px auto 0;
font-size: 14px; font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
} }
.legacy-table { .legacy-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: separate;
border-spacing: 2px;
} }
.legacy-table td, .legacy-table td,
.legacy-table th { .legacy-table th {
border: 1px solid #777; border: 0;
padding: 3px; padding: 1px;
font-weight: 400; font-weight: 400;
} }
.center { .center,
.selector,
.stats td,
.stats th,
.generals th,
.generals td:not(:last-child) {
text-align: center; text-align: center;
} }
.selector {
text-align: center;
margin-top: 0;
}
.selector select { .selector select {
display: inline-block; display: inline-block;
min-width: 400px; min-width: 400px;
height: 19px;
padding: 0;
border: 1px solid #767676;
background: #6b6b6b;
color: #fff;
font-family: Arial, sans-serif;
font-size: 13.3333px;
}
.selector {
transform: translateY(-2px);
}
.selector p {
margin: 1em 0;
}
.back-row {
margin-top: 14px;
} }
.stats { .stats {
margin-top: 14px; margin-top: 0;
table-layout: fixed;
}
.label-col {
width: 48px;
}
.value-col {
width: 108px;
}
.first-value-col {
width: 112px;
} }
.stats th, .stats th,
.generals th { .generals th {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg'); background-image: url('/image/game/back_green.jpg');
text-align: center;
}
.stats td {
text-align: center;
} }
.city-title { .city-title {
text-align: center; text-align: center;
} }
.generals { .stats {
margin-top: 14px; height: 136px;
} }
.generals td { .general-names {
text-align: center; text-align: left !important;
}
.unknown {
color: gray;
}
.generals {
width: 1024px;
margin: 18px 0 0 50%;
table-layout: fixed;
transform: translateX(-50%);
} }
.generals td:last-child { .generals td:last-child {
text-align: left; text-align: left;
padding-left: 1em; padding-left: 1em;
} }
.icon-cell {
height: 64px;
padding: 0 !important;
}
.generals tbody tr {
height: 72px;
}
.general-icon {
display: block;
width: 64px;
min-width: 64px;
height: 64px;
object-fit: fill;
}
.turns { .turns {
font-size: x-small; font-size: x-small;
} }
.turn-line {
display: block;
}
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.footer { .footer {
margin-top: 14px; margin-top: 14px;
} }
.back-link {
display: inline-block;
border: 1px solid #6c757d;
border-radius: 0.2rem;
background: #6c757d;
color: #fff;
padding: 0.25rem 0.5rem;
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1;
text-decoration: none;
}
.back-link:hover,
.back-link:focus,
.back-link:active {
border-color: #565e64;
background: #5c636a;
color: #fff;
}
.error { .error {
text-align: center; text-align: center;
color: #ff7373; color: #ff7373;
@@ -233,8 +460,5 @@ onMounted(() => void load());
width: 1000px; width: 1000px;
transform-origin: top left; transform-origin: top left;
} }
.selector select {
min-width: 300px;
}
} }
</style> </style>
+45 -5
View File
@@ -135,7 +135,7 @@ onMounted(async () => {
</select> </select>
</label> </label>
<div v-if="errorMessage" class="legacy-message error">{{ errorMessage }}</div> <div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading" class="legacy-message">불러오는 중...</div> <div v-else-if="loading" class="legacy-message">불러오는 중...</div>
<div v-else-if="!data" class="legacy-message">표시할 데이터가 없습니다.</div> <div v-else-if="!data" class="legacy-message">표시할 데이터가 없습니다.</div>
@@ -171,6 +171,10 @@ onMounted(async () => {
<div class="legacy-hall-bottom"> <div class="legacy-hall-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button> <button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div> </div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main> </main>
</template> </template>
@@ -191,7 +195,7 @@ onMounted(async () => {
.legacy-hall-title, .legacy-hall-title,
.legacy-hall-bottom { .legacy-hall-bottom {
text-align: center; text-align: left;
} }
.legacy-hall-title { .legacy-hall-title {
@@ -205,12 +209,33 @@ onMounted(async () => {
} }
.scenario-search select { .scenario-search select {
min-width: 220px; width: 189px;
height: 20px;
border: 1px solid #555; border: 1px solid #555;
background: #ddd; background: #ddd;
color: #303030; color: #303030;
} }
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message { .legacy-message {
border: 1px solid gray; border: 1px solid gray;
padding: 12px; padding: 12px;
@@ -221,6 +246,15 @@ onMounted(async () => {
color: #ff6b6b; color: #ff6b6b;
} }
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.hall-sections { .hall-sections {
display: block; display: block;
} }
@@ -235,7 +269,9 @@ onMounted(async () => {
margin: 0; margin: 0;
border-bottom: 1px solid gray; border-bottom: 1px solid gray;
padding: 2px; padding: 2px;
font-size: 1.17em; font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center; text-align: center;
} }
@@ -275,7 +311,7 @@ onMounted(async () => {
display: inline-block; display: inline-block;
width: 64px; width: 64px;
height: 64px; height: 64px;
object-fit: cover; object-fit: fill;
} }
.hall-server, .hall-server,
@@ -309,5 +345,9 @@ onMounted(async () => {
.legacy-hall-page { .legacy-hall-page {
width: 1000px; width: 1000px;
} }
.rankType {
font-size: 28px;
}
} }
</style> </style>
+545 -448
View File
@@ -1,7 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>; type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
@@ -12,8 +10,8 @@ type BuffKey =
| 'warAvoidRatio' | 'warAvoidRatio'
| 'warCriticalRatio' | 'warCriticalRatio'
| 'warMagicTrialProb' | 'warMagicTrialProb'
| 'success' | 'domesticSuccessProb'
| 'fail' | 'domesticFailProb'
| 'warAvoidRatioOppose' | 'warAvoidRatioOppose'
| 'warCriticalRatioOppose' | 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose'; | 'warMagicTrialProbOppose';
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
'warAvoidRatio', 'warAvoidRatio',
'warCriticalRatio', 'warCriticalRatio',
'warMagicTrialProb', 'warMagicTrialProb',
'success', 'domesticSuccessProb',
'fail', 'domesticFailProb',
'warAvoidRatioOppose', 'warAvoidRatioOppose',
'warCriticalRatioOppose', 'warCriticalRatioOppose',
'warMagicTrialProbOppose', 'warMagicTrialProbOppose',
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
warAvoidRatio: '회피 확률 증가', warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가', warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가', warMagicTrialProb: '전투계략 시도 확률 증가',
success: '내정 성공률 증가', domesticSuccessProb: '내정 성공률 증가',
fail: '내정 실패 감소', domesticFailProb: '내정 실패 확률 감소',
warAvoidRatioOppose: '상대 회피 확률 감소', warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소', warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
const pointLabels: Record<string, string> = { const pointLabels: Record<string, string> = {
previous: '보유', previous: '보유',
lived_month: '생존 턴', lived_month: '생존 턴',
max_domestic_critical: '내정 최고치', max_domestic_critical: '최대 연속 내정 성공',
active_action: '활동', active_action: '능동 행동 수',
combat: '전투', combat: '전투 횟수',
sabotage: '계략', sabotage: '계략 성공 횟수',
dex: '숙련', dex: '숙련',
unifier: '통일 보상', unifier: '천통 기여',
tournament: '토너먼트', tournament: '토너먼트',
betting: '베팅', betting: '베팅 당첨',
max_belong: '최대 충성', max_belong: '최대 임관년 수',
}; };
const pointOrder = [ const pointOrder = [
'previous', 'previous',
'lived_month', 'lived_month',
'max_belong',
'max_domestic_critical', 'max_domestic_critical',
'active_action', 'active_action',
'combat', 'combat',
@@ -65,9 +64,33 @@ const pointOrder = [
'unifier', 'unifier',
'tournament', 'tournament',
'betting', 'betting',
'max_belong',
] as const; ] as const;
const pointHelp: Record<string, string> = {
previous: '이전에 물려받은 포인트입니다.',
lived_month: '살아남은 기간입니다. (1개월 단위)',
max_belong: '가장 오래 임관했던 국가의 연도입니다.',
max_domestic_critical: '성공한 내정 중 최대 연속값입니다.',
active_action: '장수 동향에 본인의 이름이 직접 나타난 수입니다. 일부 사령턴은 제외됩니다.',
combat: '전투 횟수입니다.',
sabotage: '계략 성공 횟수입니다.',
unifier: '천통에 기여한 포인트입니다. 각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.',
dex: '총 숙련도합입니다. 최대 숙련 이후에는 상승량이 1/3로 감소합니다.',
tournament: '토너먼트 입상 포인트입니다.',
betting: '성공적인 베팅을 했습니다. 수익율과 베팅 성공 횟수를 따릅니다.',
};
const buffHelp: Record<BuffKey, string> = {
warAvoidRatio: '전투 시 회피 확률이 1%p ~ 5%p 증가합니다.',
warCriticalRatio: '전투 시 필살 확률이 1%p ~ 5%p 증가합니다.',
warMagicTrialProb: '전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.',
domesticSuccessProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 증가합니다.',
domesticFailProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 감소합니다.',
warAvoidRatioOppose: '전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.',
warCriticalRatioOppose: '전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.',
warMagicTrialProbOppose: '전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.',
};
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const status = ref<InheritStatus | null>(null); const status = ref<InheritStatus | null>(null);
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
warAvoidRatio: 1, warAvoidRatio: 1,
warCriticalRatio: 1, warCriticalRatio: 1,
warMagicTrialProb: 1, warMagicTrialProb: 1,
success: 1, domesticSuccessProb: 1,
fail: 1, domesticFailProb: 1,
warAvoidRatioOppose: 1, warAvoidRatioOppose: 1,
warCriticalRatioOppose: 1, warCriticalRatioOppose: 1,
warMagicTrialProbOppose: 1, warMagicTrialProbOppose: 1,
@@ -115,7 +138,7 @@ const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel); const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0)); const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
const resetStatCost = computed(() => const resetStatCost = computed(() =>
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0 resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
); );
const resetStatErrors = computed(() => { const resetStatErrors = computed(() => {
@@ -166,6 +189,9 @@ const pointEntries = computed(() => {
})); }));
}); });
const previousPoint = computed(() => status.value?.items.previous ?? 0);
const newPoint = computed(() => (status.value?.totalPoint ?? 0) - previousPoint.value);
const specialNameMap = computed(() => { const specialNameMap = computed(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const entry of status.value?.availableSpecialWar ?? []) { for (const entry of status.value?.availableSpecialWar ?? []) {
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
return map; return map;
}); });
const currentSpecialName = computed(() => {
if (!status.value) {
return '-';
}
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
});
const buffCost = (key: BuffKey, target: number): number => { const buffCost = (key: BuffKey, target: number): number => {
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0]; const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
const current = status.value?.buffLevels[key] ?? 0; const current = status.value?.buffLevels[key] ?? 0;
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0)); return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
}; };
const buffTargetOptions = (key: BuffKey): number[] => {
const current = status.value?.buffLevels[key] ?? 0;
const start = Math.min(5, Math.max(1, current + 1));
const result: number[] = [];
for (let level = start; level <= 5; level += 1) {
result.push(level);
}
return result.length > 0 ? result : [5];
};
const resolveErrorMessage = (value: unknown): string => { const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) { if (value instanceof Error) {
return value.message; return value.message;
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
return 'unknown_error'; return 'unknown_error';
}; };
const applyResetBalanced = () => {
const rules = statRules.value;
if (!rules) {
return;
}
const base = Math.floor(rules.total / 3);
resetStatForm.leadership = rules.total - base * 2;
resetStatForm.strength = base;
resetStatForm.intel = base;
};
const syncSelections = () => { const syncSelections = () => {
if (!status.value) { if (!status.value) {
return; return;
@@ -235,6 +233,14 @@ const syncSelections = () => {
if (!uniqueForm.amount) { if (!uniqueForm.amount) {
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint; uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
} }
if (!uniqueForm.itemId) {
uniqueForm.itemId = status.value.availableUnique[0]?.key ?? '';
}
if (resetStatForm.leadership === 0 && resetStatForm.strength === 0 && resetStatForm.intel === 0) {
resetStatForm.leadership = status.value.currentStat.leadership;
resetStatForm.strength = status.value.currentStat.strength;
resetStatForm.intel = status.value.currentStat.intel;
}
}; };
const loadStatus = async () => { const loadStatus = async () => {
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
const openUniqueAuction = async () => { const openUniqueAuction = async () => {
if (!uniqueForm.itemId.trim()) { if (!uniqueForm.itemId.trim()) {
actionError.value = '유니크 아이템 ID를 입력해주세요.'; actionError.value = '유니크를 선택해주세요.';
return; return;
} }
const amount = Math.max(0, Math.floor(uniqueForm.amount)); const amount = Math.max(0, Math.floor(uniqueForm.amount));
@@ -412,12 +418,6 @@ const checkOwner = async () => {
}); });
}; };
watch(statRules, (rules) => {
if (rules) {
applyResetBalanced();
}
});
onMounted(() => { onMounted(() => {
void loadStatus(); void loadStatus();
void loadJoinConfig(); void loadJoinConfig();
@@ -426,464 +426,561 @@ onMounted(() => {
</script> </script>
<template> <template>
<main class="inherit-page"> <header class="top-back-bar legacy-bg0">
<header class="inherit-header"> <RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
<div> <strong>유산 관리</strong>
<h1 class="inherit-title">유산 포인트</h1> <button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p> </header>
</div>
<div class="inherit-actions">
<button class="ghost" @click="loadStatus">새로고침</button>
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
</div>
</header>
<div v-if="error" class="inherit-error">{{ error }}</div> <main id="container" class="inherit-page legacy-bg0">
<div v-if="actionError" class="inherit-error">{{ actionError }}</div> <div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div> <div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
<div v-if="loading" class="loading-state">불러오는 중...</div>
<div v-if="loading"> <template v-else-if="status">
<SkeletonLines :lines="4" /> <section id="inheritance_list" class="point-grid">
</div> <article id="inherit_sum" class="inherit-item">
<label for="inherit_sum_value"> 포인트</label>
<input id="inherit_sum_value" :value="Math.floor(status.totalPoint).toLocaleString()" readonly />
<small>다음 플레이에서 사용할 있는 포인트입니다.</small>
</article>
<article id="inherit_previous" class="inherit-item">
<label for="inherit_previous_value">기존 포인트</label>
<input id="inherit_previous_value" :value="Math.floor(previousPoint).toLocaleString()" readonly />
<small>이전에 물려받은 포인트입니다.</small>
</article>
<article id="inherit_new" class="inherit-item">
<label for="inherit_new_value">신규 포인트</label>
<input id="inherit_new_value" :value="Math.floor(newPoint).toLocaleString()" readonly />
<small>이번 플레이에서 얻은 포인트입니다.</small>
</article>
<div class="divider"></div>
<article
v-for="entry in pointEntries.filter((item) => item.key !== 'previous')"
:id="`inherit_${entry.key}`"
:key="entry.key"
class="inherit-item"
>
<label :for="`inherit_${entry.key}_value`">{{ entry.label }}</label>
<input
:id="`inherit_${entry.key}_value`"
:value="Math.floor(entry.value).toLocaleString()"
readonly
/>
<small>{{ pointHelp[entry.key] }}</small>
</article>
</section>
<section v-else class="inherit-grid"> <section id="inheritance_store">
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황"> <h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
<div v-else class="summary-panel">
<div class="summary-head">
<div class="summary-total"> {{ status.totalPoint }} 포인트</div>
<div class="summary-state" :class="{ united: status.isUnited }">
{{ status.isUnited ? '통일 완료' : '진행 중' }}
</div>
</div>
<div class="summary-list">
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
<span>{{ entry.label }}</span>
<span>{{ entry.value }}</span>
</div>
</div>
<div class="summary-footer">
<div>현재 전투 특기: {{ currentSpecialName }}</div>
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}</div>
<div> 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}</div>
</div>
</div>
</PanelCard>
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다."> <div class="action-grid leading-actions">
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div> <article class="shop-item">
<div v-else class="buff-list"> <div class="control-row">
<div v-for="key in buffKeys" :key="key" class="buff-row"> <label for="next-special">다음 전투 특기 선택</label>
<div class="buff-info"> <select id="next-special" v-model="nextSpecialKey">
<div class="buff-name">{{ buffLabels[key] }}</div> <option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div> {{ entry.name }}
</div>
<div class="buff-action">
<select v-model.number="buffTargets[key]" class="form-input">
<option
v-for="level in buffTargetOptions(key)"
:key="level"
:value="level"
>
{{ level }} 단계
</option> </option>
</select> </select>
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div> </div>
<small
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
></small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy"
@click="reserveSpecialWar"
>
구입
</button>
</article>
<article class="shop-item">
<div class="control-row">
<label for="specific-unique">유니크 경매</label>
<select id="specific-unique" v-model="uniqueForm.itemId">
<option disabled value="">유니크 선택</option>
<option v-for="item in status.availableUnique" :key="item.key" :value="item.key">
{{ item.name }}
</option>
</select>
</div>
<div class="control-row">
<label for="specific-unique-amount">입찰 포인트</label>
<input
id="specific-unique-amount"
v-model.number="uniqueForm.amount"
type="number"
:min="status.inheritConst.inheritItemUniqueMinPoint"
:max="previousPoint"
/>
</div>
<small
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24 동안 진행됩니다.<br />{{
status.availableUnique.find((item) => item.key === uniqueForm.itemId)?.info
}}</small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy"
@click="openUniqueAuction"
>
경매 시작
</button>
</article>
</div>
<div class="divider"></div>
<div class="action-grid">
<article class="shop-item simple-item">
<div class="control-row">
<span>랜덤 초기화</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetTurnTime">
구입
</button>
</div>
<small
>다다음턴부터 시간이 랜덤하게 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
>필요 포인트: {{ status.resetCosts.resetTurnTime }}</b
><template v-if="turnTimeLabel"><br />적용 시간: {{ turnTimeLabel }}</template></small
>
</article>
<article class="shop-item simple-item">
<div class="control-row">
<span>랜덤 유니크 획득</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="buyRandomUnique">
구입
</button>
</div>
<small
>다음 턴에 랜덤 유니크를 얻습니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritItemRandomPoint }}</b
></small
>
</article>
<article class="shop-item simple-item">
<div class="control-row">
<span>즉시 전투 특기 초기화</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetSpecialWar">
구입
</button>
</div>
<small
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
>필요 포인트: {{ status.resetCosts.resetSpecialWar }}</b
></small
>
</article>
</div>
<div class="divider"></div>
<div class="buff-grid">
<article v-for="key in buffKeys" :key="key" class="shop-item buff-item">
<div class="control-row">
<label :for="`buff-${key}`">{{ buffLabels[key] }}</label>
<input
:id="`buff-${key}`"
v-model.number="buffTargets[key]"
type="number"
:min="status.buffLevels[key] ?? 0"
max="5"
/>
</div>
<small
>{{ buffHelp[key] }}<br /><b>필요 포인트: {{ buffCost(key, buffTargets[key]) }}</b></small
>
<div class="dual-buttons">
<button <button
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5" class="legacy-button secondary"
:disabled="actionBusy"
@click="buffTargets[key] = status.buffLevels[key] ?? 0"
>
리셋
</button>
<button
class="legacy-button"
:disabled="isUnited || actionBusy"
@click="buyHiddenBuff(key)" @click="buyHiddenBuff(key)"
> >
구입 구입
</button> </button>
</div> </div>
</div> </article>
</div> </div>
</PanelCard>
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화"> <div class="divider"></div>
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack"> <div class="action-grid bottom-actions">
<div class="action-row"> <article class="shop-item">
<label class="form-field"> <div class="control-row">
<span>다음 전투 특기</span> <label for="owner-target">장수 소유자 확인</label>
<select v-model="nextSpecialKey" class="form-input"> <select id="owner-target" v-model="ownerTargetId">
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key"> <option disabled value="">장수 선택</option>
{{ special.name }} <option
v-for="general in status.availableTargetGenerals"
:key="general.id"
:value="String(general.id)"
>
{{ general.name }}
</option> </option>
</select> </select>
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small> </div>
</label> <small
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button> >장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
</div> >필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
<div class="action-row"> ></small
<div> >
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div> <button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
<div class="muted"> 소유자 찾기
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트 </button>
({{ status.resetLevels.resetSpecialWar }}) <p v-if="ownerResult" class="owner-result">
{{ ownerResult.targetName }} 소유자: {{ ownerResult.ownerName }}
</p>
</article>
<article class="shop-item stat-reset">
<div class="stat-layout">
<span>능력치 초기화</span>
<div>
<strong>기본 능력치</strong>
<label
>
<input
v-model.number="resetStatForm.leadership"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<label
>
<input
v-model.number="resetStatForm.strength"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<label
>
<input
v-model.number="resetStatForm.intel"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<strong>추가 능력치</strong>
<label
> <input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5"
/></label>
<label
> <input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5"
/></label>
<label
> <input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5"
/></label>
</div> </div>
</div> </div>
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button> <small
</div> >시즌 1회에 능력치를 초기화합니다.<br /><b>필요 포인트: {{ resetStatCost }}</b
><br /><span v-if="resetStatErrors.length">{{ resetStatErrors[0] }}</span></small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy || resetStatErrors.length > 0"
@click="resetStats"
>
능력치 초기화
</button>
</article>
</div> </div>
</PanelCard> </section>
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다."> <section class="inherit-logs">
<div v-if="!status" class="muted"> 시간 정보를 불러오지 못했습니다.</div> <h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
<div v-else class="action-stack"> <div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
<div class="action-row"> <div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<div class="muted"> <div v-for="entry in logs" v-else :key="entry.id" class="log-row">
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }}) <small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
</div> <span>{{ entry.text }}</span>
<button :disabled="isUnited || actionBusy" @click="resetTurnTime"> 시간 변경</button>
</div>
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
</div> </div>
</PanelCard> <button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
가져오기
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다."> </button>
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div> </section>
<div v-else class="stat-panel"> </template>
<div class="stat-grid">
<label class="form-field">
<span>통솔</span>
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
</label>
<label class="form-field">
<span>무력</span>
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
</label>
<label class="form-field">
<span>지력</span>
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
</label>
</div>
<div class="stat-grid">
<label class="form-field">
<span>보너스 통솔</span>
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
</label>
<label class="form-field">
<span>보너스 무력</span>
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
</label>
<label class="form-field">
<span>보너스 지력</span>
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
</label>
</div>
<div class="stat-summary">
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
<div>보너스 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
<div v-if="resetStatErrors.length" class="stat-errors">
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
</div>
</div>
<div class="action-row">
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
</div>
</div>
</PanelCard>
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
</div>
<div class="action-row">
<label class="form-field">
<span>유니크 아이템 ID</span>
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
</label>
<label class="form-field">
<span>입찰 포인트</span>
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
</div>
</div>
</PanelCard>
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<label class="form-field">
<span>대상 장수</span>
<select v-model="ownerTargetId" class="form-input">
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
{{ general.name }}
</option>
</select>
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
<div v-if="ownerResult" class="muted">
{{ ownerResult.targetName }} 소유자: {{ ownerResult.ownerName }}
</div>
</div>
</PanelCard>
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
<template #actions>
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
</template>
<div v-if="logLoading && logs.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
<div v-else class="log-list">
<div v-for="entry in logs" :key="entry.id" class="log-entry">
<span class="log-date">{{ entry.year }} {{ entry.month }}</span>
<span>{{ entry.text }}</span>
</div>
</div>
<div class="log-footer">
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()"> 보기</button>
</div>
</PanelCard>
</section>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.inherit-page { .top-back-bar {
min-height: 100vh; width: min(100%, 1000px);
padding: 24px; min-height: 38px;
display: flex; margin: 0 auto;
flex-direction: column; border: 1px solid #888;
gap: 16px;
}
.inherit-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
}
.inherit-title {
font-size: 1.6rem;
font-weight: 600;
}
.inherit-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.inherit-actions {
display: flex;
gap: 8px;
}
.inherit-error {
border: 1px solid rgba(240, 90, 90, 0.6);
padding: 8px 10px;
color: rgba(240, 150, 150, 0.9);
}
.inherit-message {
border: 1px solid rgba(120, 190, 120, 0.5);
padding: 8px 10px;
color: rgba(180, 230, 180, 0.9);
}
.inherit-grid {
display: grid; display: grid;
gap: 16px; grid-template-columns: 100px 1fr 100px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.summary-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.summary-head {
display: flex;
align-items: center; align-items: center;
justify-content: space-between; text-align: center;
gap: 8px; padding: 3px 6px;
box-sizing: border-box;
} }
.summary-total { .top-button {
font-size: 1rem;
font-weight: 600;
}
.summary-state {
font-size: 0.75rem;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid rgba(201, 164, 90, 0.4); text-decoration: none;
} }
.summary-state.united { .inherit-page {
border-color: rgba(240, 120, 120, 0.6); width: min(100%, 1000px);
color: rgba(240, 150, 150, 0.9); margin: 0 auto;
border: 1px solid #888;
border-top: 0;
overflow: hidden;
box-sizing: border-box;
padding: 0 8px 10px;
color: #fff;
font:
14px/1.3 Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
} }
.summary-list { .notice,
display: flex; .loading-state,
flex-direction: column; .log-empty {
gap: 4px; padding: 10px;
text-align: center;
} }
.summary-row { .notice.error {
display: flex; color: #ffb0b0;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
} }
.summary-footer { .notice.success {
display: flex; color: #b6efb6;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
} }
.buff-list { .point-grid,
display: flex; .action-grid,
flex-direction: column; .buff-grid {
gap: 10px;
}
.buff-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 8px;
}
.buff-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.buff-name {
font-weight: 600;
font-size: 0.85rem;
}
.buff-level {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.7);
}
.buff-action {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.buff-cost {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.action-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.stat-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-grid {
display: grid; display: grid;
gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
} }
.stat-summary { .inherit-item,
font-size: 0.75rem; .shop-item {
color: rgba(232, 221, 196, 0.7); padding: 8px 16px;
display: flex; box-sizing: border-box;
flex-direction: column; min-width: 0;
gap: 4px;
} }
.stat-errors { .inherit-item {
color: rgba(240, 150, 150, 0.9); display: grid;
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
align-items: start;
gap: 6px;
} }
.form-field { .inherit-item label {
text-align: right;
padding: 7px 8px 0 0;
}
.inherit-item input,
.shop-item input,
.shop-item select {
width: 100%;
min-width: 0;
border: 1px solid #6c757d;
border-radius: 4px;
background: #212529;
color: #fff;
padding: 6px 8px;
box-sizing: border-box;
}
.inherit-item small,
.shop-item small {
grid-column: 1 / -1;
min-height: 34px;
text-align: right;
color: #aeb2b6;
}
.inherit-item small {
min-height: 36px;
}
.divider {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.22);
margin: 4px 2px;
}
.section-title {
font-size: 14px;
font-weight: 400;
text-align: center;
margin: 0 -8px;
padding: 2px;
}
.leading-actions .shop-item:first-child {
grid-column: 2;
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 8px;
min-height: 36px;
}
.control-row > label,
.control-row > span {
text-align: right;
}
.shop-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
font-size: 0.75rem;
} }
.form-input { .shop-item .buy-button {
border: 1px solid rgba(201, 164, 90, 0.4); width: 50%;
background: rgba(10, 10, 10, 0.8); margin-left: auto;
padding: 6px 8px;
color: inherit;
} }
button { .simple-item small {
border: 1px solid rgba(201, 164, 90, 0.4); min-height: 55px;
padding: 6px 12px;
font-size: 0.8rem;
background: rgba(16, 16, 16, 0.6);
color: inherit;
} }
button.ghost { .buff-item small {
background: transparent; min-height: 72px;
} }
.log-list { .dual-buttons {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 1fr 1fr;
}
.legacy-button.secondary {
border-color: #51585e;
background: #5c636a;
}
.bottom-actions .shop-item:first-child {
grid-column: 1;
}
.stat-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
text-align: right;
} }
.log-entry { .stat-layout > div {
display: flex; display: grid;
flex-direction: column; gap: 3px;
gap: 2px;
font-size: 0.8rem;
} }
.log-date { .stat-layout label {
font-size: 0.7rem; display: grid;
color: rgba(232, 221, 196, 0.6); grid-template-columns: 22px 1fr;
align-items: center;
} }
.log-footer { .stat-layout strong {
margin-top: 8px; text-align: left;
font-size: 12px;
} }
.muted { .owner-result {
color: rgba(232, 221, 196, 0.6); margin: 0;
font-size: 0.75rem; color: #fff;
text-align: right;
}
.inherit-logs {
margin: 8px 0 0;
}
.log-row {
display: grid;
grid-template-columns: minmax(150px, 20ch) 1fr;
gap: 8px;
padding: 3px 8px;
}
.log-row small {
color: #aeb2b6;
text-align: right;
font-variant-numeric: tabular-nums;
}
.more-button {
width: 100%;
margin-top: 6px;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
@media (max-width: 767px) {
.point-grid,
.action-grid,
.buff-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.leading-actions .shop-item:first-child {
grid-column: 1;
}
}
@media (max-width: 575px) {
.top-back-bar,
.inherit-page {
width: 500px;
max-width: 100%;
}
.point-grid,
.action-grid,
.buff-grid {
grid-template-columns: 1fr;
}
.divider {
grid-column: 1;
}
.inherit-item,
.shop-item {
padding-left: 16px;
padding-right: 16px;
}
.log-row {
grid-template-columns: 1fr;
}
.log-row small {
text-align: left;
}
} }
</style> </style>
+13 -10
View File
@@ -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);
} }
+57 -34
View File
@@ -50,7 +50,7 @@ const {
reservedNationTurns, reservedNationTurns,
messageDraftText, messageDraftText,
targetMailbox, targetMailbox,
mailboxOptions, mailboxGroups,
statusLine, statusLine,
realtimeLabel, realtimeLabel,
} = storeToRefs(dashboard); } = storeToRefs(dashboard);
@@ -104,6 +104,10 @@ watch(
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink> <RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink> <RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink> <RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="ghost" to="/nation/secret"
>암행부</RouterLink
>
<span v-else class="ghost disabled" aria-disabled="true">암행부</span>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink> <RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink> <RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink> <RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
@@ -115,10 +119,11 @@ watch(
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink> <RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink> <RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink> <RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink> <RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a> <a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink> <RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink> <RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament" <RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink >토너먼트</RouterLink
> >
@@ -216,22 +221,26 @@ watch(
</div> </div>
<div v-if="mobileTab === 'messages'" class="mobile-panel"> <div v-if="mobileTab === 'messages'" class="mobile-panel">
<PanelCard title="메시지함"> <MessagePanel
<MessagePanel class="mobile-message-panel"
:messages="messages" :messages="messages"
:loading="loading" :loading="loading"
:target-mailbox="targetMailbox" :target-mailbox="targetMailbox"
:draft-text="messageDraftText" :draft-text="messageDraftText"
:mailbox-options="mailboxOptions" :mailbox-groups="mailboxGroups"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false" :general-id="general?.id ?? 0"
@update:target-mailbox="targetMailbox = $event" :general-name="general?.name ?? ''"
@update:draft-text="messageDraftText = $event" :nation-id="general?.nationId ?? 0"
@send="dashboard.sendMessage" :can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@load-older="dashboard.loadOlderMessages" @update:target-mailbox="targetMailbox = $event"
@refresh="dashboard.refreshMessages" @update:draft-text="messageDraftText = $event"
@respond="dashboard.respondToMessage" @send="dashboard.sendMessage"
/> @load-older="dashboard.loadOlderMessages"
</PanelCard> @refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
@read-latest="dashboard.readLatestMessage"
@delete="dashboard.deleteMessage"
/>
</div> </div>
</section> </section>
@@ -251,22 +260,6 @@ watch(
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div> <div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
</div> </div>
</PanelCard> </PanelCard>
<PanelCard title="메시지함">
<MessagePanel
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-options="mailboxOptions"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
/>
</PanelCard>
</div> </div>
<div class="stack"> <div class="stack">
@@ -302,6 +295,26 @@ watch(
<div v-else class="placeholder">개인 기록 영역</div> <div v-else class="placeholder">개인 기록 영역</div>
</PanelCard> </PanelCard>
</div> </div>
<MessagePanel
class="desktop-message-panel"
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-groups="mailboxGroups"
:general-id="general?.id ?? 0"
:general-name="general?.name ?? ''"
:nation-id="general?.nationId ?? 0"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
@read-latest="dashboard.readLatestMessage"
@delete="dashboard.deleteMessage"
/>
</section> </section>
</main> </main>
</template> </template>
@@ -396,6 +409,16 @@ button {
gap: 16px; gap: 16px;
} }
.desktop-message-panel {
grid-column: 1 / -1;
}
.mobile-message-panel {
width: 100vw;
min-width: 0;
margin-left: -24px;
}
.layout-mobile { .layout-mobile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+543 -467
View File
@@ -1,19 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
const SCREEN_MODE_KEY = 'sammo-screen-mode'; const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>; type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = { type WorldSnapshot = {
currentYear: number; currentYear: number;
currentMonth: number; currentMonth: number;
tickSeconds: number; tickSeconds: number;
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
meta: Record<string, unknown>; meta: Record<string, unknown>;
} | null; } | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type SettingForm = {
tnmt: number;
type LogLine = { defence_train: number;
id: number; use_treatment: number;
html: string; use_auto_nation_turn: number;
};
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type ItemSlot = {
key: ItemSlotKey;
label: string;
code: string | null;
};
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
}; };
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null); const screenMode = ref<ScreenMode>('auto');
const worldState = ref<WorldStateSnapshot>(null); const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const logs = reactive<Record<LogType, LogLine[]>>({ const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
const logLabels: Record<LogType, string> = {
generalAction: '개인 기록',
battleDetail: '전투 기록',
generalHistory: '장수 열전',
battleResult: '전투 결과',
};
const logColors: Record<LogType, string> = {
generalAction: 'skyblue',
battleDetail: 'orange',
generalHistory: 'skyblue',
battleResult: 'orange',
};
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
generalHistory: [], generalHistory: [],
battleDetail: [], battleDetail: [],
battleResult: [], battleResult: [],
generalAction: [], generalAction: [],
}); });
const logLoading = reactive<Record<LogType, boolean>>({ const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false, generalHistory: false,
battleDetail: false, battleDetail: false,
battleResult: false, battleResult: false,
generalAction: false, generalAction: false,
}); });
const logHasMore = reactive<Record<LogType, boolean>>({ const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true, generalHistory: true,
battleDetail: true, battleDetail: true,
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
generalAction: true, generalAction: true,
}); });
const activeLogTab = ref<LogType>('generalAction'); const errorText = (value: unknown): string =>
const isMobile = useMediaQuery('(max-width: 1024px)'); value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const resolveErrorMessage = (value: unknown): string => { const asRecord = (value: unknown): Record<string, unknown> =>
if (value instanceof Error) { value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
return value.message;
} const numberValue = (value: unknown, fallback: number): number => {
if (typeof value === 'string') { const parsed = typeof value === 'number' ? value : Number(value);
return value; return Number.isFinite(parsed) ? parsed : fallback;
}
return 'unknown_error';
}; };
const resolveNumber = (value: unknown, fallback: number): number => { const statusLine = computed(() =>
if (typeof value === 'number' && Number.isFinite(value)) { world.value
return value; ? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
} 1,
if (typeof value === 'string') { Math.round(world.value.tickSeconds / 60)
const parsed = Number(value); )} `
if (Number.isFinite(parsed)) { : '내 정보를 불러오는 중'
return parsed; );
}
}
return fallback;
};
const statusLine = computed(() => { const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
if (!worldState.value) { const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
return '내 정보를 불러오는 중'; const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
} { key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0); { key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : ''; { key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
return `${worldState.value.currentYear}${worldState.value.currentMonth}${termLabel}`; ]);
});
const itemSlots = computed<ItemSlot[]>(() => {
const items = data.value?.general?.items;
return [
{ key: 'horse', label: '말', code: items?.horse ?? null },
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
{ key: 'book', label: '서적', code: items?.book ?? null },
{ key: 'item', label: '아이템', code: items?.item ?? null },
];
});
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
const showVacation = computed(() => !autorunUser.value.limit_minutes);
const actionAvailability = computed(() => { const actionAvailability = computed(() => {
const general = data.value?.general; const general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>; const meta = world.value?.meta ?? {};
const config = (worldState.value?.config ?? {}) as Record<string, unknown>; const config = world.value?.config ?? {};
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>; const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null; const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null; const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime()); const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
const npcMode = resolveNumber(config.npcMode, 0);
return { return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0), dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0), buildNationCandidate: Boolean(preopen && general?.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false), instantRetreat: Boolean(availableInstantAction.instantRetreat),
canInstantRetreat: Boolean(general && general.nationId > 0), selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
}; };
}); });
const loadLogs = async () => { const applyCustomCss = (text: string) => {
if (!data.value?.general?.id) { let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
return; if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
} }
style.textContent = text;
await Promise.all(
logTypes.map((type) => loadLog(type))
);
}; };
const loadLog = async (type: LogType, beforeId?: number) => { const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) { if (logLoading[type]) return;
return;
}
logLoading[type] = true; logLoading[type] = true;
try { try {
const response = await trpc.general.getMyLog.query({ type, beforeId }); const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({ const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
id: entry.id, logs[type] = beforeId ? [...logs[type], ...next] : next;
html: formatLog(entry.text), logHasMore[type] = next.length >= 24;
})); } catch (cause) {
error.value = errorText(cause);
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
} finally { } finally {
logLoading[type] = false; logLoading[type] = false;
} }
}; };
const loadMyPage = async () => { const loadPage = async () => {
if (loading.value) { if (loading.value) return;
return;
}
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const general = await trpc.general.me.query(); const [general, state] = await Promise.all([
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)(); trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
]);
data.value = general; data.value = general;
worldState.value = world ?? null; world.value = state;
await loadLogs(); if (general) {
} catch (err) { Object.assign(form, general.settings);
error.value = resolveErrorMessage(err); }
await Promise.all(logTypes.map((type) => loadLog(type)));
} catch (cause) {
error.value = errorText(cause);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const confirmAction = async (message: string, action: () => Promise<void>) => { const saveSettings = async () => {
if (!confirm(message)) { if (!canSave.value) return;
return;
}
try { try {
await action(); await trpc.general.setMySetting.mutate({ ...form });
await loadMyPage(); await loadPage();
} catch (err) { } catch (cause) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`); alert(`실패했습니다: ${errorText(cause)}`);
} }
}; };
const handleDieOnPrestart = () => const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
confirmAction('정말로 삭제하시겠습니까?', async () => { if (!confirm(message)) return;
await trpc.general.dieOnPrestart.mutate(); try {
window.location.reload(); await mutation();
}); await loadPage();
} catch (cause) {
const handleBuildNationCandidate = () => alert(`실패했습니다: ${errorText(cause)}`);
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
await trpc.general.buildNationCandidate.mutate();
});
const handleInstantRetreat = () =>
confirmAction('아군 접경으로 이동할까요?', async () => {
await trpc.general.instantRetreat.mutate();
});
const handleVacation = () =>
confirmAction('휴가 기능을 신청할까요?', async () => {
await trpc.general.vacation.mutate();
});
const handleDropItem = (slot: ItemSlot) =>
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
await trpc.general.dropItem.mutate({ itemType: slot.key });
});
const refreshScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
if (mode === '500px' || mode === '1000px') {
screenMode.value = mode;
} else {
screenMode.value = 'auto';
} }
}; };
watch( const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
() => isMobile.value, confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
(value) => { trpc.general.dropItem.mutate({ itemType: item.key })
if (value && !logTypes.includes(activeLogTab.value)) { );
activeLogTab.value = 'generalAction';
} watch(screenMode, (mode) => {
} localStorage.setItem(SCREEN_MODE_KEY, mode);
); document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => { onMounted(() => {
refreshScreenMode(); const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
void loadMyPage(); screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
}); });
</script> </script>
<template> <template>
<main class="my-page" :class="`screen-${screenMode}`"> <main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<header class="page-header"> <div class="title-row">
<div> <span> </span>
<h1 class="page-title"> 정보</h1> <RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<p class="page-subtitle">{{ statusLine }}</p> <button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div> </div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
<button class="ghost" @click="loadMyPage">새로고침</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div>
<section class="layout-grid"> <section class="top-grid">
<div class="stack"> <div class="general-column">
<PanelCard title="장수 상태"> <div class="section-title sky">장수 정보</div>
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" /> <div v-if="loading || !data" class="loading">불러오는 중...</div>
</PanelCard> <div v-else class="general-table">
<PanelCard title="도시 상태"> <div class="portrait-cell">
<CityBasicCard :city="data?.city ?? null" :loading="loading" /> <img
</PanelCard> :src="
<PanelCard title="세력 상태"> data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" /> "
</PanelCard> alt=""
</div> />
<strong>{{ data.general.name }}</strong>
<div class="stack">
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
<div class="action-grid">
<button
v-if="actionAvailability.canVacation"
class="action-btn"
type="button"
@click="handleVacation"
>
휴가 신청
</button>
<button
v-if="actionAvailability.canDieOnPrestart"
class="action-btn"
type="button"
@click="handleDieOnPrestart"
>
장수 삭제
</button>
<button
v-if="actionAvailability.canBuildNationCandidate"
class="action-btn"
type="button"
@click="handleBuildNationCandidate"
>
사전 거병
</button>
<button
v-if="actionAvailability.canInstantRetreat"
class="action-btn"
type="button"
@click="handleInstantRetreat"
>
접경 귀환
</button>
<RouterLink
v-if="actionAvailability.canSelectOtherGeneral"
class="action-btn link"
to="/join"
>
다른 장수 선택
</RouterLink>
</div> </div>
</PanelCard> <dl>
<div>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다."> <dt>통솔</dt>
<div class="item-grid"> <dd>{{ data.general.stats.leadership }}</dd>
<button
v-for="slot in itemSlots"
:key="slot.key"
class="item-btn"
type="button"
:disabled="!slot.code"
@click="handleDropItem(slot)"
>
{{ slot.label }}: {{ slot.code ?? '-' }}
</button>
</div>
</PanelCard>
<PanelCard v-if="isMobile" title="장수 기록">
<div class="log-tabs">
<button
v-for="type in logTypes"
:key="type"
:class="{ active: activeLogTab === type }"
@click="activeLogTab = type"
>
{{ logLabels[type] }}
</button>
</div>
<div class="log-block">
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[activeLogTab]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<button
v-if="logHasMore[activeLogTab]"
class="ghost log-more"
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</PanelCard>
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
<div class="log-grid">
<div v-for="type in logTypes" :key="type" class="log-block">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<button
v-if="logHasMore[type]"
class="ghost log-more"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div> </div>
</div> <div>
</PanelCard> <dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
</dl>
</div>
</div> </div>
<div class="settings-column">
<div class="setting-line">
토너먼트
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
</div>
<div class="hint"> 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
<label class="setting-line">
환약 사용
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용안함</option>
</select>
</label>
<div class="hint"> 부상을 입었을 환약을 사용하는 기준입니다.</div>
<label v-if="showAutoNationTurn" class="setting-line">
자동 사령턴 허용
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
</select>
</label>
<button
id="set_my_setting"
class="action-button"
type="button"
:hidden="!canSave"
@click="saveSettings"
>
설정저장
</button>
<div class="hint"> 설정저장은 이달중 {{ data?.settings.myset ?? 0 }} 남았습니다.</div>
<div v-if="penalties.length" class="penalties">
징계 목록(저장 갱신)
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
</div>
<div v-if="showVacation" class="action-line">
<br />
<button
class="action-button"
type="button"
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
>
휴가 신청
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제<br />
<button
class="action-button"
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
>
장수 삭제
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate()
)
"
>
사전 거병
</button>
</div>
<div v-if="actionAvailability.instantRetreat" class="action-line">
거리 3 이내 아국 도시로 즉시 이동<br />
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
"
>
접경 귀환
</button>
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
v-for="item in items"
:key="item.key"
type="button"
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.code ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<button
v-if="logHasMore[type]"
class="load-old"
type="button"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</div>
</article>
</section> </section>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.my-page { .legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh; min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
margin: 0 auto; margin: 0 auto;
padding: 0;
color: #fff;
background-color: #111;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.3;
} }
.legacy-page.screen-500px {
.my-page.screen-500px {
max-width: 500px; max-width: 500px;
} }
.legacy-page.screen-1000px {
.my-page.screen-1000px {
max-width: 1000px; max-width: 1000px;
} }
.title-row {
.page-header { height: 54px;
display: flex; display: flex;
align-items: center; align-content: flex-start;
justify-content: space-between; align-items: flex-start;
gap: 16px; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
} }
.title-row > span {
.page-title { flex-basis: 100%;
font-size: 1.6rem; height: 18px;
letter-spacing: 0;
}
.legacy-button,
button,
select,
textarea {
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #6b6b6b;
font: inherit;
}
.legacy-button {
min-height: 34px;
padding: 5px 10px;
border-color: #2d5d7f;
border-radius: 4px;
background: #315f86;
color: #fff;
font-weight: 700; font-weight: 700;
text-decoration: none;
letter-spacing: 0;
} }
button {
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.action-btn {
border: 1px solid rgba(201, 164, 90, 0.5);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
cursor: pointer; cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center; text-align: center;
} }
.error-row {
.action-btn.link { color: #ff7777;
display: inline-flex; border: 1px solid #a33;
align-items: center;
justify-content: center;
} }
.top-grid,
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.item-btn {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 10px;
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.item-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.log-grid { .log-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
} }
.general-column,
.log-block { .settings-column,
border: 1px solid rgba(201, 164, 90, 0.3); .log-panel {
padding: 8px; border: 1px solid #666;
background: rgba(12, 12, 12, 0.6); background-image: url('/image/game/back_walnut.jpg');
min-height: 160px;
} }
.section-title,
.log-title { .log-panel h2 {
font-weight: 600; min-height: 34px;
margin-bottom: 6px; margin: 0;
font-size: 0.9rem; display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
font-size: 1.25em;
font-weight: 500;
} }
.sky {
.log-line { color: skyblue;
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
} }
.general-table {
.log-line:last-child { display: grid;
border-bottom: none; grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
} }
.portrait-cell {
.log-more { display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-cell img {
width: 64px;
height: 64px;
object-fit: cover;
}
dl {
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: 80px 1fr;
border-bottom: 1px solid #777;
}
dt,
dd {
margin: 0;
padding: 2px 5px;
border-right: 1px solid #777;
}
dt {
color: #aaa;
}
.settings-column {
padding: 10px 18px;
}
.setting-line {
display: block;
margin-top: 5px;
}
.hint {
margin: 0 0 13px;
color: orange;
}
.action-button {
width: 160px;
height: 30px;
margin: 4px 0;
background: #225500;
}
.action-line {
margin: 12px 0;
}
.penalties {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
.item-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 5px 0 14px;
}
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
.log-panel h2 {
color: orange;
}
.log-line,
.empty,
.loading {
padding: 2px 8px;
}
.load-old {
width: 100%;
min-height: 32px;
margin-top: 8px; margin-top: 8px;
} }
@media (max-width: 991px) {
.log-tabs { .legacy-page {
display: flex; width: 500px;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.log-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.log-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
} }
.top-grid,
.log-grid { .log-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+131 -60
View File
@@ -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 {
+199 -322
View File
@@ -1,349 +1,226 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat'; import { formatOfficerLevelText } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type GeneralListResponse = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>; type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type GeneralEntry = GeneralListResponse['generals'][number]; type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
const data = ref<Result | null>(null);
type SortKey = const error = ref('');
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 13
| 14
| 15;
const sortOptions: Array<{ key: SortKey; label: string }> = [
{ key: 1, label: '관직' },
{ key: 2, label: '공헌' },
{ key: 3, label: '경험' },
{ key: 4, label: '통솔' },
{ key: 5, label: '무력' },
{ key: 6, label: '지력' },
{ key: 7, label: '자금' },
{ key: 8, label: '군량' },
{ key: 9, label: '병사' },
{ key: 10, label: '벌점' },
{ key: 11, label: '성격' },
{ key: 12, label: '내특' },
{ key: 13, label: '전특' },
{ key: 14, label: '사관' },
{ key: 15, label: 'NPC' },
];
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const sort = ref<Sort>(1);
const data = ref<GeneralListResponse | null>(null); const options = [
const sortKey = ref<SortKey>(1); '관직',
const filterText = ref(''); '계급',
'명성',
const resolveErrorMessage = (value: unknown): string => { '통솔',
if (value instanceof Error) { '무력',
return value.message; '지력',
} '자금',
if (typeof value === 'string') { '군량',
return value; '병사',
} '벌점',
return 'unknown_error'; '성격',
}; '내특',
'전특',
const loadGenerals = async () => { '사관',
if (loading.value) { 'NPC',
return; ];
} const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const load = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = '';
try { try {
data.value = await trpc.nation.getGeneralList.query(); data.value = await trpc.nation.getGeneralList.query();
} catch (err) { } catch (cause) {
error.value = resolveErrorMessage(err); error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.';
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const generals = computed(() =>
const sortGenerals = (list: GeneralEntry[]): GeneralEntry[] => { [...(data.value?.generals ?? [])].sort((a, b) => {
const key = sortKey.value; if (sort.value === 1) return b.officerLevel - a.officerLevel || a.id - b.id;
const sorted = [...list].sort((lhs, rhs) => { if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
switch (key) { if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
case 1: if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
return rhs.officerLevel - lhs.officerLevel; if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
case 2: if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
return rhs.dedication - lhs.dedication; if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
case 3: if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
return rhs.experience - lhs.experience; if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
case 4: if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
return rhs.stats.leadership - lhs.stats.leadership; if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
case 5: if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
return rhs.stats.strength - lhs.stats.strength; if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
case 6: if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
return rhs.stats.intelligence - lhs.stats.intelligence; if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
case 7: return a.id - b.id;
return rhs.gold - lhs.gold; })
case 8: );
return rhs.rice - lhs.rice; const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
case 9: onMounted(load);
return rhs.crew - lhs.crew;
case 10:
return 0;
case 11:
return (lhs.personality?.name ?? '').localeCompare(rhs.personality?.name ?? '');
case 12:
return (lhs.specialDomestic?.name ?? '').localeCompare(rhs.specialDomestic?.name ?? '');
case 13:
return (lhs.specialWar?.name ?? '').localeCompare(rhs.specialWar?.name ?? '');
case 14:
return rhs.belong - lhs.belong;
case 15:
return rhs.npcState - lhs.npcState;
default:
return 0;
}
});
if (key === 11 || key === 12 || key === 13) {
return sorted;
}
return sorted;
};
const filteredGenerals = computed(() => {
const list = data.value?.generals ?? [];
const keyword = filterText.value.trim().toLowerCase();
const filtered = keyword
? list.filter((general) => {
return (
general.name.toLowerCase().includes(keyword) ||
(general.cityName ?? '').toLowerCase().includes(keyword) ||
(general.officerCityName ?? '').toLowerCase().includes(keyword)
);
})
: list;
return sortGenerals(filtered);
});
const nationLevel = computed(() => data.value?.nation.level ?? 0);
const formatSpecial = (general: GeneralEntry): string => {
const domestic = general.specialDomestic?.name ?? '-';
const war = general.specialWar?.name ?? '-';
return `${domestic} / ${war}`;
};
onMounted(() => {
void loadGenerals();
});
</script> </script>
<template> <template>
<main class="nation-page"> <main class="general-page legacy-bg0">
<header class="page-header"> <header>
<div> <strong>세력 장수</strong>
<h1 class="page-title">세력 장수</h1> <span
<p class="page-subtitle">세력 장수 현황 정렬</p> ><RouterLink to="/">돌아가기</RouterLink>
</div> <button :disabled="loading" @click="load">새로고침</button></span
<div class="header-actions"> >
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<button class="ghost" @click="loadGenerals">새로고침</button>
</div>
</header> </header>
<section class="sort">
<div v-if="error" class="error">{{ error }}</div> 정렬순서 :
<select v-model.number="sort" aria-label="세력 장수 정렬">
<PanelCard title="세력 장수 목록" subtitle="국가 소속 장수들을 확인합니다."> <option v-for="(label, index) in options" :key="label" :value="index + 1">{{ label }}</option>
<template #actions> </select>
<div class="toolbar-actions"> <button>정렬하기</button>
<select v-model.number="sortKey" class="select-input"> <small v-if="data">열람 등급 {{ data.viewer.permission }}</small>
<option v-for="option in sortOptions" :key="option.key" :value="option.key"> </section>
{{ option.label }} <p v-if="error" class="state error" role="alert">{{ error }}</p>
</option> <p v-else-if="loading" class="state">불러오는 중...</p>
</select> <div v-else class="scroll">
<input v-model="filterText" class="filter-input" placeholder="이름/도시 검색" /> <table id="nation-general-list">
</div> <thead>
</template> <tr>
<th> </th>
<div class="list-meta"> {{ filteredGenerals.length }}</div> <th> </th>
<th>통무지</th>
<SkeletonLines v-if="loading" :lines="6" /> <th>명성/계급</th>
<div v-else class="table-scroll"> <th>자금</th>
<table class="nation-table"> <th>군량</th>
<thead> <th>도시</th>
<tr> <th>부대</th>
<th>이름</th> <th>병사</th>
<th>관직</th> <th>성격</th>
<th>공헌</th> <th>특기</th>
<th>경험</th> <th>사관</th>
<th>통솔</th> <th>벌점</th>
<th>무력</th> </tr>
<th>지력</th> </thead>
<th>자금</th> <tbody>
<th>군량</th> <tr v-for="general in generals" :key="general.id">
<th>병사</th> <td :class="`npc-${general.npcState}`">{{ general.name }}</td>
<th>성격</th> <td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<th>특기</th> <td>
<th>사관</th> {{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}
<th>현재 도시</th> </td>
<th>관직 도시</th> <td>
</tr> Lv {{ general.experienceLevel }}<br />{{
</thead> general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'
<tbody> }}
<tr v-for="general in filteredGenerals" :key="general.id"> </td>
<td> <td>{{ general.gold.toLocaleString() }}</td>
<span v-if="general.npcState > 0" class="npc-tag">NPC</span> <td>{{ general.rice.toLocaleString() }}</td>
{{ general.name }} <td>{{ general.cityName ?? '?' }}</td>
</td> <td>{{ general.troopName ?? '?' }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, nationLevel) }}</td> <td>{{ visibleCrew(general)?.toLocaleString() ?? '?' }}</td>
<td>{{ general.dedication }}</td> <td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
<td>{{ general.experience }}</td> <td
<td>{{ general.stats.leadership }}</td> :title="
<td>{{ general.stats.strength }}</td> [general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
<td>{{ general.stats.intelligence }}</td> "
<td>{{ general.gold }}</td> >
<td>{{ general.rice }}</td> {{ special(general) }}
<td>{{ general.crew }}</td> </td>
<td>{{ general.personality?.name ?? '-' }}</td> <td>{{ general.belong }}</td>
<td>{{ formatSpecial(general) }}</td> <td>{{ general.refreshScoreTotal }}</td>
<td>{{ general.belong > 0 ? general.belong : '-' }}</td> </tr>
<td>{{ general.cityName ?? '-' }}</td> </tbody>
<td>{{ general.officerCityName ?? '-' }}</td> </table>
</tr> </div>
</tbody> <footer><RouterLink to="/">돌아가기</RouterLink></footer>
</table>
</div>
</PanelCard>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.nation-page { .general-page {
width: 1000px;
min-height: 100vh; min-height: 100vh;
padding: 24px; margin: 8px auto 0;
font:
16px 'Times New Roman',
serif;
color: #fff;
}
header,
.sort,
footer,
.state {
position: relative;
border: 1px solid #777;
padding: 4px;
text-align: center;
}
header {
min-height: 39px;
display: flex; display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 16, 0.6);
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.select-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.filter-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.list-meta {
margin-bottom: 8px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.table-scroll {
overflow-x: auto;
max-height: 70vh;
overflow-y: auto;
}
.nation-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.nation-table th,
.nation-table td {
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
text-align: left;
white-space: nowrap;
}
.nation-table thead th {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.npc-tag {
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.6rem; }
padding: 2px 4px; header span {
position: absolute;
right: 6px;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.sort small {
float: right;
margin-right: 6px; margin-right: 6px;
border: 1px solid rgba(201, 164, 90, 0.4); color: #ccc;
color: rgba(232, 221, 196, 0.8); }
.scroll {
width: 1030px;
margin-left: -15px;
min-height: calc(100vh - 112px);
overflow: auto;
}
table {
width: 1030px;
min-width: 1030px;
border-collapse: separate;
table-layout: fixed;
}
th,
td {
border: 1px solid #777;
padding: 3px;
text-align: center;
overflow-wrap: anywhere;
}
th {
height: 30px;
background: #14241b url('/image/game/back_green.jpg');
font-weight: 400;
}
tbody tr {
height: 66px;
background: rgb(0 0 0 / 18%);
}
.npc-1 {
color: cyan;
}
.npc-2,
.npc-3,
.npc-4,
.npc-5 {
color: #aaa;
}
.error {
color: #ff7373;
}
@media (max-width: 1000px) {
.general-page {
margin: 8px 0 0;
}
} }
</style> </style>
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const data = ref<Result | null>(null);
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(7);
const options = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
const load = async () => {
loading.value = true;
error.value = '';
try {
data.value = await trpc.nation.getSecretGeneralList.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '암행부를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const generals = computed(() =>
[...(data.value?.generals ?? [])].sort((a, b) => {
if (sort.value === 1) return b.gold - a.gold || a.id - b.id;
if (sort.value === 2) return b.rice - a.rice || a.id - b.id;
if (sort.value === 3) return a.cityId - b.cityId || a.id - b.id;
if (sort.value === 4) return b.crewTypeId - a.crewTypeId || a.id - b.id;
if (sort.value === 5) return b.crew - a.crew || a.id - b.id;
if (sort.value === 6) return a.killTurn - b.killTurn || a.id - b.id;
if (sort.value === 7) return a.turnTime.localeCompare(b.turnTime) || a.id - b.id;
return b.troopId - a.troopId || a.id - b.id;
})
);
onMounted(load);
</script>
<template>
<main class="secret-page">
<table class="layout legacy-bg0 title">
<tbody>
<tr>
<td> <br /><RouterLink to="/"> 닫기</RouterLink></td>
</tr>
<tr>
<td>
정렬순서 :
<select v-model.number="sort" aria-label="암행부 정렬">
<option v-for="(label, index) in options" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<button>정렬하기</button> <button :disabled="loading" @click="load">새로고침</button>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="state error legacy-bg0" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state legacy-bg0">불러오는 중...</p>
<template v-else-if="data">
<table class="layout summary legacy-bg0">
<tbody>
<tr>
<th>전체 </th>
<td>{{ data.summary.gold.toLocaleString() }}</td>
<th>전체 </th>
<td>{{ data.summary.rice.toLocaleString() }}</td>
<th>평균 </th>
<td>{{ data.summary.averageGold.toFixed(2) }}</td>
<th>평균 </th>
<td>{{ data.summary.averageRice.toFixed(2) }}</td>
</tr>
<tr>
<th>전체 병력/장수</th>
<td>{{ data.summary.crew.toLocaleString() }}/{{ data.summary.generalCount }}</td>
<template v-for="level in [90, 80, 60] as const" :key="level"
><th>훈사 {{ level }} 병력/장수</th>
<td>
{{ data.summary.readiness[level].crew.toLocaleString() }}/{{
data.summary.readiness[level].generals
}}
</td></template
>
</tr>
</tbody>
</table>
<table id="secret-general-list" class="layout list legacy-bg0">
<thead>
<tr>
<th> </th>
<th>통무지</th>
<th> </th>
<th> </th>
<th> </th>
<th>도시</th>
<th></th>
<th> </th>
<th> </th>
<th>훈련</th>
<th>사기</th>
<th class="commands"> </th>
<th>삭턴</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td>{{ general.name }}<br />Lv {{ general.experienceLevel }}</td>
<td>
{{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}
</td>
<td>{{ general.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.defenceTrainText }}</td>
<td>{{ general.crewTypeId }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.train }}</td>
<td>{{ general.atmos }}</td>
<td class="turns">
<template v-if="general.npcState >= 2">NPC 장수</template
><template v-else
><div v-for="(command, index) in general.reservedCommands" :key="index">
{{ index + 1 }} : {{ command }}
</div></template
>
</td>
<td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(11, 16) }}</td>
</tr>
</tbody>
</table>
</template>
<table class="layout legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/"> 닫기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.secret-page {
width: 1000px;
margin: 8px auto 0;
font:
16px 'Times New Roman',
serif;
color: #fff;
}
.layout {
width: 1000px;
border-collapse: collapse;
table-layout: fixed;
}
td,
th,
.state {
border: 1px solid #777;
padding: 3px;
text-align: center;
font-weight: 400;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.summary {
margin: 5px auto;
}
.summary th,
.list th {
background: #14241b url('/image/game/back_green.jpg');
}
.summary th {
width: 120px;
}
.list {
width: 1030px;
margin-left: -15px;
border-collapse: separate;
}
.list tbody tr {
height: 39px;
}
.commands {
width: 213px;
}
.turns {
text-align: left;
font-size: 11px;
}
.error {
color: #ff7373;
}
.footer {
margin-top: 5px;
}
@media (max-width: 1000px) {
.secret-page {
margin: 8px 0 0;
}
}
</style>
+693 -691
View File
@@ -1,78 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { npcPriorityHelp } from '../utils/npcPriorityHelp'; import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>; type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy']; type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type PolicyKey = keyof NationPolicy; type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
type PolicyField = { type PrioritySectionKey = 'nation' | 'general';
key: PolicyKey; type PriorityBucket = 'active' | 'inactive';
interface PolicyField {
key: NumericPolicyKey;
label: string; label: string;
step: number; step: number;
description: string; description: string;
hint?: string; hint?: string;
percent?: boolean; percent?: boolean;
}; min?: number;
max?: number;
}
type PolicySection = { interface PriorityListState {
title: string;
fields: PolicyField[];
};
const NUMERIC_POLICY_KEYS = [
'reqNationGold',
'reqNationRice',
'reqHumanWarUrgentGold',
'reqHumanWarUrgentRice',
'reqHumanWarRecommandGold',
'reqHumanWarRecommandRice',
'reqHumanDevelGold',
'reqHumanDevelRice',
'reqNPCWarGold',
'reqNPCWarRice',
'reqNPCDevelGold',
'reqNPCDevelRice',
'minimumResourceActionAmount',
'maximumResourceActionAmount',
'minNPCWarLeadership',
'minWarCrew',
'minNPCRecruitCityPopulation',
'safeRecruitCityPopulationRatio',
'properWarTrainAtmos',
'cureThreshold',
] as const;
type NumericPolicyKey = (typeof NUMERIC_POLICY_KEYS)[number];
type PrioritySectionKey = 'nation' | 'general';
type PriorityListState = {
active: string[]; active: string[];
inactive: string[]; inactive: string[];
available: string[]; available: string[];
}; }
interface PriorityPanel {
key: PrioritySectionKey;
title: string;
description: string[];
setter: NpcPolicyResponse['lastSetters']['nation'];
state: PriorityListState;
}
interface DragState {
section: PrioritySectionKey;
bucket: PriorityBucket;
index: number;
}
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const notice = ref<string | null>(null);
const data = ref<NpcPolicyResponse | null>(null); const data = ref<NpcPolicyResponse | null>(null);
const policyDraft = ref<NationPolicy | null>(null); const policyDraft = ref<NationPolicy | null>(null);
const lastSavedPolicy = ref<NationPolicy | null>(null); const lastSavedPolicy = ref<NationPolicy | null>(null);
const nationPriority = ref<PriorityListState | null>(null); const nationPriority = ref<PriorityListState | null>(null);
const generalPriority = ref<PriorityListState | null>(null); const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]); const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]); const lastSavedGeneralPriority = ref<string[]>([]);
const dragState = ref<DragState | null>(null);
const resolveErrorMessage = (value: unknown): string => { const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) { if (value instanceof Error) return value.message;
return value.message; if (typeof value === 'string') return value;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error'; return 'unknown_error';
}; };
@@ -85,851 +68,870 @@ const clonePolicy = (source: NationPolicy): NationPolicy => ({
const assignPriorityState = (active: string[], available: string[]): PriorityListState => { const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
const activeSet = new Set(active); const activeSet = new Set(active);
const inactive = available.filter((item) => !activeSet.has(item));
return { return {
active: [...active], active: [...active],
inactive, inactive: available.filter((item) => !activeSet.has(item)),
available: [...available], available: [...available],
}; };
}; };
const loadPolicy = async () => { const loadPolicy = async () => {
if (loading.value) { if (loading.value) return;
return;
}
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
data.value = await trpc.npc.getPolicy.query(); data.value = await trpc.npc.getPolicy.query();
} catch (err) { } catch (caught) {
error.value = resolveErrorMessage(err); error.value = resolveErrorMessage(caught);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
onMounted(() => { watch(data, (value) => {
void loadPolicy(); if (!value) return;
policyDraft.value = clonePolicy(value.currentNationPolicy);
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
generalPriority.value = assignPriorityState(
value.currentGeneralActionPriority,
value.availableGeneralActionPriorityItems
);
lastSavedNationPriority.value = [...value.currentNationPriority];
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
}); });
watch( onMounted(() => void loadPolicy());
() => data.value,
(value) => {
if (!value) {
return;
}
policyDraft.value = clonePolicy(value.currentNationPolicy);
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
generalPriority.value = assignPriorityState(
value.currentGeneralActionPriority,
value.availableGeneralActionPriorityItems
);
lastSavedNationPriority.value = [...value.currentNationPriority];
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
}
);
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value)); const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
const calcPolicyValue = (key: NumericPolicyKey): number => { const calcPolicyValue = (key: NumericPolicyKey): number => {
if (!data.value || !policyDraft.value) { if (!data.value || !policyDraft.value) return 0;
return 0;
}
const value = policyDraft.value[key]; const value = policyDraft.value[key];
if (value === 0) { return value === 0 ? data.value.zeroPolicy[key] : value;
return data.value.zeroPolicy[key];
}
return value;
}; };
const safeRecruitPercent = computed({ const safeRecruitPercent = computed({
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100, get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
set: (value: number) => { set: (value: number) => {
if (!policyDraft.value) { if (policyDraft.value) policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
return;
}
policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
}, },
}); });
const policySections = computed<PolicySection[]>(() => { const policyFields = computed<PolicyField[]>(() => {
if (!data.value) { if (!data.value) return [];
return [];
}
const statMax = data.value.defaultStatMax; const statMax = data.value.defaultStatMax;
const statNpcMax = data.value.defaultStatNpcMax; const statNpcMax = data.value.defaultStatNpcMax;
return [ return [
{ {
title: '국가 재정', key: 'reqNationGold',
fields: [ label: '국가 권장 금',
{ step: 100,
key: 'reqNationGold', description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
],
}, },
{ {
title: '유저 전투장', key: 'reqNationRice',
fields: [ label: '국가 권장 쌀',
{ step: 100,
key: 'reqHumanWarUrgentGold', description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
label: '긴급포상 금',
step: 100,
description:
'유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100 * 6)}) 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentGold
)}입니다.`,
},
{
key: 'reqHumanWarUrgentRice',
label: '긴급포상 쌀',
step: 100,
description:
'유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100 * 6)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentRice
)}입니다.`,
},
{
key: 'reqHumanWarRecommandGold',
label: '권장 금',
step: 100,
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 금의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentGold') * 2
)}입니다.`,
},
{
key: 'reqHumanWarRecommandRice',
label: '권장 쌀',
step: 100,
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 쌀의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentRice') * 2
)}입니다.`,
},
],
}, },
{ {
title: '유저 내정장', key: 'reqHumanWarUrgentGold',
fields: [ label: '유저전투장 긴급포상 금',
{ step: 100,
key: 'reqHumanDevelGold', description: '유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
label: '권장 금', hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100)} * 6) 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentGold)}입니다.`,
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqHumanDevelRice',
label: '권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
],
}, },
{ {
title: 'NPC 전투장', key: 'reqHumanWarUrgentRice',
fields: [ label: '유저전투장 긴급포상 쌀',
{ step: 100,
key: 'reqNPCWarGold', description: '유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
label: '권장 금', hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100)} * 6명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentRice)}입니다.`,
step: 100,
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100 * 4)}) 징병비를 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarGold
)}입니다.`,
},
{
key: 'reqNPCWarRice',
label: '권장 쌀',
step: 100,
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100 * 4)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarRice
)}입니다.`,
},
],
}, },
{ {
title: 'NPC 내정장', key: 'reqHumanWarRecommandGold',
fields: [ label: '유저전투장 권장 금',
{ step: 100,
key: 'reqNPCDevelGold', description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
label: '권장 금', hint: `0이면 유저전투장 긴급포상 금의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentGold') * 2)}입니다.`,
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCDevelGold
)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: '권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
],
}, },
{ {
title: '자원 정책', key: 'reqHumanWarRecommandRice',
fields: [ label: '유저전투장 권장 쌀',
{ step: 100,
key: 'minimumResourceActionAmount', description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
label: '포상/몰수/헌납 최소 단위', hint: `0이면 유저전투장 긴급포상 쌀의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentRice') * 2)}입니다.`,
step: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납 최대 단위',
step: 100,
description: '연산결과가 이 단위보다 크다면 이 값에 맞춥니다.',
},
],
}, },
{ {
title: '전투/징병 기준', key: 'reqHumanDevelGold',
fields: [ label: '유저내정장 권장 금',
{ step: 100,
key: 'minWarCrew', description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
label: '최소 전투 가능 병력 수',
step: 50,
description: '이보다 적을 때에는 징병을 시도합니다.',
},
{
key: 'minNPCRecruitCityPopulation',
label: 'NPC 최소 징병 가능 인구 수',
step: 100,
description:
'도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
},
{
key: 'safeRecruitCityPopulationRatio',
label: '제자리 징병 허용 인구율(%)',
step: 0.5,
description:
'전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
percent: true,
},
{
key: 'minNPCWarLeadership',
label: 'NPC 전투 참여 통솔 기준',
step: 5,
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
},
],
}, },
{ {
title: '상태 기준', key: 'reqHumanDevelRice',
fields: [ label: '유저내정장 권장 쌀',
{ step: 100,
key: 'properWarTrainAtmos', description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
label: '훈련/사기진작 목표치', },
step: 5, {
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.', key: 'reqNPCWarGold',
}, label: 'NPC전투장 권장 금',
{ step: 100,
key: 'cureThreshold', description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
label: '요양 기준(%)', hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100)} * 4) 징병비를 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarGold)}입니다.`,
step: 5, },
description: '요양 기준입니다. 이보다 많이 부상을 입으면 요양합니다.', {
}, key: 'reqNPCWarRice',
], label: 'NPC전투장 권장 쌀',
step: 100,
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100)} * 4명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarRice)}입니다.`,
},
{
key: 'reqNPCDevelGold',
label: 'NPC내정장 권장 금',
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCDevelGold)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: 'NPC내정장 권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
{
key: 'minimumResourceActionAmount',
label: '포상/몰수/헌납/삼/팜 최소 단위',
step: 100,
min: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납/삼/팜 최대 단위',
step: 100,
min: 100,
description: '연산결과가 이 단위보다 크다면, 이 값에 맞춥니다.',
},
{
key: 'minWarCrew',
label: '최소 전투 가능 병력 수',
step: 50,
description: '이보다 적을 때에는 징병을 시도합니다.',
},
{
key: 'minNPCRecruitCityPopulation',
label: 'NPC 최소 징병 가능 인구 수',
step: 100,
description: '도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
},
{
key: 'safeRecruitCityPopulationRatio',
label: '제자리 징병 허용 인구율(%)',
step: 0.5,
min: 0,
max: 100,
percent: true,
description: '전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
},
{
key: 'minNPCWarLeadership',
label: 'NPC 전투 참여 통솔 기준',
step: 5,
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
},
{
key: 'properWarTrainAtmos',
label: '훈련/사기진작 목표치',
step: 5,
min: 20,
max: 100,
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
},
{
key: 'cureThreshold',
label: '요양 기준',
step: 5,
min: 10,
max: 100,
description: '요양 기준 %입니다. 이보다 많이 부상을 입으면 요양합니다.',
}, },
]; ];
}); });
const canEdit = computed(() => (data.value?.permissionLevel ?? 0) >= 3); const priorityPanels = computed<PriorityPanel[]>(() => {
if (!data.value || !nationPriority.value || !generalPriority.value) return [];
return [
{
key: 'nation',
title: 'NPC 사령턴 우선순위',
description: ['예턴이 없거나, 지정되어 있더라도 실패하면', '아래 순위에 따라 사령턴을 시도합니다.'],
setter: data.value.lastSetters.nation,
state: nationPriority.value,
},
{
key: 'general',
title: 'NPC 일반턴 우선순위',
description: [
'순위가 높은 것부터 시도합니다.',
'아무것도 실행할 수 없으면 물자조달이나 인재탐색을 합니다.',
],
setter: data.value.lastSetters.general,
state: generalPriority.value,
},
];
});
const resetPolicy = () => { const resetPolicy = () => {
if (!data.value) { if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
policyDraft.value = clonePolicy(data.value.defaultNationPolicy); policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
}; };
const rollbackPolicy = () => { const rollbackPolicy = () => {
if (!lastSavedPolicy.value) { if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
return;
}
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
policyDraft.value = clonePolicy(lastSavedPolicy.value); policyDraft.value = clonePolicy(lastSavedPolicy.value);
notice.value = '이전 설정으로 되돌렸습니다.';
}; };
const submitPolicy = async () => { const submitPolicy = async () => {
if (!policyDraft.value) { if (!policyDraft.value || !window.confirm('저장할까요?')) return;
return; error.value = null;
} notice.value = null;
if (!window.confirm('저장할까요?')) {
return;
}
try { try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value); await trpc.npc.setNationPolicy.mutate(policyDraft.value);
await loadPolicy(); lastSavedPolicy.value = clonePolicy(policyDraft.value);
} catch (err) { notice.value = 'NPC 정책이 반영되었습니다.';
error.value = resolveErrorMessage(err); } catch (caught) {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
} }
}; };
const moveItem = (list: string[], from: number, to: number) => {
if (to < 0 || to >= list.length) {
return;
}
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
};
const insertByOrder = (list: string[], item: string, orderMap: Map<string, number>) => {
const targetOrder = orderMap.get(item) ?? Number.MAX_SAFE_INTEGER;
const index = list.findIndex((entry) => (orderMap.get(entry) ?? Number.MAX_SAFE_INTEGER) > targetOrder);
if (index === -1) {
list.push(item);
} else {
list.splice(index, 0, item);
}
};
const togglePriority = (section: PrioritySectionKey, item: string, enable: boolean) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
if (enable) {
const index = target.inactive.indexOf(item);
if (index >= 0) {
target.inactive.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.active, item, orderMap);
}
return;
}
const index = target.active.indexOf(item);
if (index >= 0) {
target.active.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.inactive, item, orderMap);
}
};
const reorderPriority = (section: PrioritySectionKey, index: number, direction: number) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
moveItem(target.active, index, index + direction);
};
const resetPriority = (section: PrioritySectionKey) => { const resetPriority = (section: PrioritySectionKey) => {
if (!data.value) { if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (section === 'nation') { if (section === 'nation') {
nationPriority.value = assignPriorityState(data.value.defaultNationPriority, data.value.availableNationPriorityItems); nationPriority.value = assignPriorityState(
data.value.defaultNationPriority,
data.value.availableNationPriorityItems
);
} else { } else {
generalPriority.value = assignPriorityState( generalPriority.value = assignPriorityState(
data.value.defaultGeneralActionPriority, data.value.defaultGeneralActionPriority,
data.value.availableGeneralActionPriorityItems data.value.availableGeneralActionPriorityItems
); );
} }
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
}; };
const rollbackPriority = (section: PrioritySectionKey) => { const rollbackPriority = (section: PrioritySectionKey) => {
if (!window.confirm('이전 설정으로 되돌릴까요?')) { if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
return; if (section === 'nation') {
} nationPriority.value = assignPriorityState(
if (section === 'nation' && data.value) { lastSavedNationPriority.value,
nationPriority.value = assignPriorityState(lastSavedNationPriority.value, data.value.availableNationPriorityItems); data.value.availableNationPriorityItems
} );
if (section === 'general' && data.value) { } else {
generalPriority.value = assignPriorityState( generalPriority.value = assignPriorityState(
lastSavedGeneralPriority.value, lastSavedGeneralPriority.value,
data.value.availableGeneralActionPriorityItems data.value.availableGeneralActionPriorityItems
); );
} }
notice.value = '이전 설정으로 되돌렸습니다.';
}; };
const submitPriority = async (section: PrioritySectionKey) => { const submitPriority = async (section: PrioritySectionKey) => {
if (!data.value) { const state = section === 'nation' ? nationPriority.value : generalPriority.value;
return; if (!state || !window.confirm('저장할까요?')) return;
} error.value = null;
if (!window.confirm('저장할까요?')) { notice.value = null;
return;
}
try { try {
if (section === 'nation' && nationPriority.value) { if (section === 'nation') {
await trpc.npc.setNationPriority.mutate(nationPriority.value.active); await trpc.npc.setNationPriority.mutate(state.active);
lastSavedNationPriority.value = [...state.active];
} else {
await trpc.npc.setGeneralPriority.mutate(state.active);
lastSavedGeneralPriority.value = [...state.active];
} }
if (section === 'general' && generalPriority.value) { notice.value = 'NPC 정책이 반영되었습니다.';
await trpc.npc.setGeneralPriority.mutate(generalPriority.value.active); } catch (caught) {
} error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
} }
}; };
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
dragState.value = { section, bucket, index };
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
event.preventDefault();
const source = dragState.value;
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!source || source.section !== section || !state) return;
const sourceList = state[source.bucket];
const targetList = state[bucket];
const [item] = sourceList.splice(source.index, 1);
if (!item) return;
let index = targetIndex ?? targetList.length;
if (sourceList === targetList && source.index < index) index -= 1;
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
dragState.value = null;
};
</script> </script>
<template> <template>
<main class="npc-page"> <main id="npc-policy-page" class="npc-page">
<header class="page-header"> <nav class="top-back-bar legacy-bg0">
<div> <RouterLink class="back-button" to="/">돌아가기</RouterLink>
<h1 class="page-title">NPC 정책</h1> <strong>NPC 정책</strong>
<p class="page-subtitle">사령/일반 AI 우선순위 자원 기준</p> </nav>
<div v-if="loading && !data" class="page-state legacy-bg0">불러오는 중...</div>
<div v-else-if="!data" class="page-state error-state legacy-bg0" role="alert">
{{ error ?? 'NPC 정책을 불러오지 못했습니다.' }}
<button type="button" @click="loadPolicy">다시 시도</button>
</div>
<section v-else-if="policyDraft" id="container" class="policy-container legacy-bg0">
<div class="section_bar legacy-bg1">국가 정책</div>
<div class="setter">
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
data.lastSetters.policy.date ?? '설정 기록 없음'
}})
</div> </div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink> <div v-if="error" class="feedback error-feedback" role="alert">{{ error }}</div>
<button class="ghost" @click="loadPolicy">새로고침</button> <div v-if="notice" class="feedback notice-feedback" role="status">{{ notice }}</div>
<div class="form_list">
<div v-for="field in policyFields" :key="field.key" class="policy-field">
<div class="field-row">
<label :for="`npc-policy-${field.key}`">{{ field.label }}</label>
<input
v-if="field.percent"
:id="`npc-policy-${field.key}`"
v-model.number="safeRecruitPercent"
type="number"
:step="field.step"
:min="field.min"
:max="field.max"
/>
<input
v-else
:id="`npc-policy-${field.key}`"
v-model.number="policyDraft[field.key]"
type="number"
:step="field.step"
:min="field.min"
:max="field.max"
/>
</div>
<p>{{ field.description }}</p>
<p v-if="field.hint">{{ field.hint }}</p>
</div>
</div> </div>
</header>
<div v-if="error" class="error">{{ error }}</div> <div class="work-in-progress">
전투 부대는 작업중입니다(json양식: {부대번호:[시작도시번호(아국),도착도시번호(적국)],...})
<br />후방 징병 부대는 작업중입니다(json양식: [부대번호,...]) <br />내정 부대는 작업중입니다(json양식:
[부대번호,...])
<input type="hidden" :value="JSON.stringify(policyDraft.CombatForce)" />
<input type="hidden" :value="JSON.stringify(policyDraft.SupportForce)" />
<input type="hidden" :value="JSON.stringify(policyDraft.DevelopForce)" />
</div>
<section v-if="loading && !data"> <div class="control_bar">
<PanelCard title="NPC 정책 로딩"> <div class="button-group">
<SkeletonLines :lines="6" /> <button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
</PanelCard> <button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
</section>
<section v-else-if="data && policyDraft" class="npc-layout">
<PanelCard title="국가 정책" subtitle="NPC 자원 기준과 전투 판단 기준">
<div class="setter">
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
data.lastSetters.policy.date ?? '설정 기록 없음'
}})
</div> </div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div> <button class="submit_btn" type="button" @click="submitPolicy">설정</button>
<div v-for="section in policySections" :key="section.title" class="policy-section"> </div>
<h3 class="section-title">{{ section.title }}</h3>
<div class="policy-grid">
<div
v-for="field in section.fields"
:key="field.key"
class="policy-field"
>
<label class="field-label">{{ field.label }}</label>
<input
v-if="field.percent"
v-model.number="safeRecruitPercent"
type="number"
class="field-input"
:step="field.step"
min="0"
max="100"
:disabled="!canEdit"
/>
<input
v-else
v-model.number="policyDraft[field.key as PolicyKey]"
type="number"
class="field-input"
:step="field.step"
min="0"
:disabled="!canEdit"
/>
<p class="field-desc">{{ field.description }}</p>
<p v-if="field.hint" class="field-hint">{{ field.hint }}</p>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPolicy">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPolicy">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPolicy">설정</button>
</div>
</PanelCard>
<div class="priority-grid"> <div class="priority-sections">
<PanelCard title="NPC 사령턴 우선순위" subtitle="예턴 실패 시 우선순위대로 실행"> <section
<div class="setter"> v-for="panel in priorityPanels"
최근 설정: {{ data.lastSetters.nation.setter ?? '-없음-' }} ({{ :key="panel.key"
data.lastSetters.nation.date ?? '설정 기록 없음' :class="['priority-panel', panel.key === 'nation' ? 'half_section_left' : 'half_section_right']"
}}) >
<div class="section_bar legacy-bg1">{{ panel.title }}</div>
<div class="priority-meta">
<small>
최근 설정: {{ panel.setter.setter ?? '-없음-' }} ({{
panel.setter.date ?? '설정 기록 없음'
}})
</small>
</div>
<div class="priority-description">
<small>{{ panel.description[0] }}<br />{{ panel.description[1] }}</small>
</div> </div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns"> <div class="priority-columns">
<div class="priority-column"> <div class="priority-column">
<div class="column-title">비활성</div> <div class="sub_bar legacy-bg2">비활성</div>
<div class="priority-list"> <div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'inactive')"
>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
<div <div
v-for="item in nationPriority?.inactive ?? []" v-for="(item, index) in panel.state.inactive"
:key="item" :key="item"
class="priority-item" class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'inactive', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
> >
<span class="priority-name">{{ item }}</span> <div class="priority_info">
<span <span class="drag-handle"></span>
class="priority-help" <span>{{ item }}</span>
:data-text="npcPriorityHelp[item] ?? '설명 없음'" <button
> class="help-button"
? type="button"
</span> :aria-label="`${item} 설명`"
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, true)"> :data-text="npcPriorityHelp[item] ?? '설명 없음'"
활성 >
</button> ?
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="priority-column"> <div class="priority-column">
<div class="column-title">활성</div> <div class="sub_bar legacy-bg2">활성</div>
<div class="priority-list"> <div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'active')"
>
<div <div
v-for="(item, idx) in nationPriority?.active ?? []" v-for="(item, index) in panel.state.active"
:key="item" :key="`${item}-${index}`"
class="priority-item" class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'active', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'active', index)"
> >
<div class="priority-main"> <div class="priority_info">
<span class="priority-name">{{ item }}</span> <span class="drag-handle"></span>
<span <span>{{ item }}</span>
class="priority-help" <button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'" :data-text="npcPriorityHelp[item] ?? '설명 없음'"
> >
? ?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, false)">
비활성
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="control-bar"> <div class="control_bar priority-control">
<div class="btn-group"> <div class="button-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('nation')">초깃값으로</button> <button class="reset_btn" type="button" @click="resetPriority(panel.key)">
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('nation')">이전값으로</button> 초깃값으로
</button>
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
이전값으로
</button>
</div> </div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('nation')">설정</button> <button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
</div> </div>
</PanelCard> </section>
<PanelCard title="NPC 일반턴 우선순위" subtitle="순위가 높은 것부터 시도">
<div class="setter">
최근 설정: {{ data.lastSetters.general.setter ?? '-없음-' }} ({{
data.lastSetters.general.date ?? '설정 기록 없음'
}})
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns">
<div class="priority-column">
<div class="column-title">비활성</div>
<div class="priority-list">
<div
v-for="item in generalPriority?.inactive ?? []"
:key="item"
class="priority-item"
>
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, true)">
활성
</button>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div
v-for="(item, idx) in generalPriority?.active ?? []"
:key="item"
class="priority-item"
>
<div class="priority-main">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, false)">
비활성
</button>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('general')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('general')">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('general')">설정</button>
</div>
</PanelCard>
</div> </div>
</section> </section>
</main> </main>
</template> </template>
<style scoped> <style scoped>
:global(html),
:global(body),
:global(#app) {
min-width: 500px;
margin: 0;
background: #000;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
:global(body:has(#npc-policy-page)) {
min-width: 500px;
line-height: 21px;
}
.npc-page { .npc-page {
min-height: 100vh; min-height: 100vh;
padding: 24px; color: #fff;
display: flex; font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
flex-direction: column; font-size: 14px;
gap: 16px; line-height: 21px;
} }
.page-header { .legacy-bg0 {
display: flex; background-image: url('/image/game/back_walnut.jpg');
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
} }
.page-title { .legacy-bg1 {
font-size: 1.6rem; background-image: url('/image/game/back_green.jpg');
font-weight: 600;
} }
.page-subtitle { .legacy-bg2 {
font-size: 0.85rem; background-image: url('/image/game/back_blue.jpg');
color: rgba(232, 221, 196, 0.7);
} }
.header-actions { .top-back-bar {
display: flex; position: relative;
flex-wrap: wrap; height: 32px;
gap: 8px; max-width: 1000px;
margin: 0 auto;
text-align: center;
box-sizing: border-box;
} }
.ghost { .top-back-bar strong {
border: 1px solid rgba(201, 164, 90, 0.4); font-size: 24px;
padding: 6px 12px; line-height: 32px;
font-size: 0.8rem; font-weight: 400;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
} }
.primary { .back-button {
border: 1px solid rgba(201, 164, 90, 0.6); position: absolute;
padding: 6px 12px; inset: 0 auto 0 0;
font-size: 0.8rem; width: 88px;
cursor: pointer; color: #fff;
background: rgba(201, 164, 90, 0.2); background: #087f45;
color: inherit; border: 1px solid #0a9960;
border-radius: 0 0 4px;
font-weight: 700;
line-height: 30px;
text-decoration: none;
} }
.error { .back-button:hover,
color: #f5b7b1; .back-button:focus-visible {
font-size: 0.85rem; background: #0a9960;
outline: 2px solid #fff;
outline-offset: -2px;
} }
.npc-layout { .policy-container,
display: flex; .page-state {
flex-direction: column; width: 100%;
gap: 16px; max-width: 1000px;
margin: 0 auto;
border: 1px solid #888;
box-sizing: border-box;
} }
.setter { .page-state {
font-size: 0.75rem; padding: 16px;
color: rgba(232, 221, 196, 0.6); min-height: 100px;
margin-bottom: 8px;
} }
.readonly-note { .page-state button {
font-size: 0.75rem; margin-left: 12px;
color: rgba(245, 208, 138, 0.8);
margin-bottom: 8px;
} }
.policy-section { .section_bar {
margin-top: 12px; min-height: 23px;
border: 0.5px solid #aaa;
box-sizing: border-box;
text-align: center;
} }
.section-title { .setter,
font-size: 0.95rem; .priority-meta {
margin-bottom: 8px; min-height: 21px;
color: rgba(232, 221, 196, 0.9); padding: 0 12px;
color: #8e8e8e;
font-size: 12.25px;
line-height: 18.375px;
text-align: right;
box-sizing: border-box;
} }
.policy-grid { .feedback {
margin: 4px 12px;
padding: 5px 10px;
border: 1px solid;
border-radius: 4px;
}
.error-feedback,
.error-state {
color: #ffd4d4;
border-color: #a94442;
background-color: rgba(120, 20, 20, 0.75);
}
.notice-feedback {
color: #d9ffd9;
border-color: #3c763d;
background-color: rgba(20, 90, 20, 0.7);
}
.form_list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; margin: 8px;
} }
.policy-field { .policy-field {
border: 1px solid rgba(201, 164, 90, 0.2); min-width: 0;
background: rgba(16, 16, 16, 0.5); padding: 0 10.5px;
padding: 10px; box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 6px;
} }
.field-label { .field-row {
font-size: 0.8rem;
font-weight: 600;
}
.field-input {
background: rgba(12, 12, 12, 0.8);
border: 1px solid rgba(201, 164, 90, 0.3);
color: inherit;
padding: 6px 8px;
font-size: 0.8rem;
font-family: inherit;
}
.field-desc {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.field-hint {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.45);
white-space: pre-line;
}
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.btn-group {
display: flex;
gap: 8px;
}
.priority-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: minmax(0, 1fr) 224px;
gap: 16px; align-items: center;
min-height: 34px;
}
.field-row label {
min-width: 0;
}
.field-row input {
width: 224px;
height: 34px;
padding: 5.25px 10.5px;
color: #303030;
background: #ddd;
border: 1px solid #000;
border-radius: 4px;
box-sizing: border-box;
font: inherit;
}
.field-row input:focus {
border-color: #66afe9;
outline: 2px solid rgba(102, 175, 233, 0.7);
}
.policy-field p {
min-height: 18.375px;
margin: 0;
color: #888;
font-size: 12.25px;
line-height: 18.375px;
text-align: right;
}
.work-in-progress {
margin: 0 11px 15px;
padding: 14px;
color: #fff;
background: #444;
border: 1px solid #444;
border-radius: 4px;
}
.control_bar {
display: flex;
justify-content: flex-end;
padding: 0 10.6667px 10.6667px;
min-height: 56.8125px;
box-sizing: border-box;
}
.button-group {
display: flex;
}
.control_bar button {
width: 150px;
height: 35.5px;
margin-top: 10.6667px;
padding: 5.25px 10.5px;
color: #fff;
border: 1px solid;
font: inherit;
cursor: pointer;
}
.reset_btn {
background: #303030;
border-color: #2b2b2b !important;
border-radius: 4px 0 0 4px;
}
.revert_btn {
background: #444;
border-color: #3d3d3d !important;
border-radius: 0 4px 4px 0;
}
.submit_btn {
margin-left: 14px;
background: #375a7f;
border-color: #325172 !important;
border-radius: 4px;
}
.control_bar button:hover {
filter: brightness(1.15);
}
.control_bar button:focus-visible,
.help-button:focus-visible {
outline: 2px solid #fff;
outline-offset: -2px;
}
.priority-sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.priority-panel {
min-width: 0;
}
.half_section_left {
border-right: 0.5px solid #aaa;
}
.priority-meta {
float: right;
}
.priority-description {
clear: both;
min-height: 42px;
padding: 0 8px;
color: #888;
box-sizing: border-box;
}
.priority-description small {
font-size: 12.25px;
line-height: 18.375px;
} }
.priority-columns { .priority-columns {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
} }
.priority-column { .priority-column {
border: 1px solid rgba(201, 164, 90, 0.2); min-width: 0;
padding: 8px;
background: rgba(12, 12, 12, 0.5);
} }
.column-title { .sub_bar {
font-size: 0.8rem; height: 22px;
margin-bottom: 6px; margin: 0 5px;
color: rgba(232, 221, 196, 0.7); border: 0.5px solid #aaa;
text-align: center;
box-sizing: border-box;
} }
.priority-list { .priority-list {
display: flex; display: flex;
min-height: 37px;
margin: 0 10px;
flex-direction: column; flex-direction: column;
gap: 6px; }
.inactive-header,
.priority-item {
height: 37px;
padding: 7px 14px;
border: 1px solid #444;
box-sizing: border-box;
}
.inactive-header {
color: #1d1d1d;
background: #d6d6d6;
} }
.priority-item { .priority-item {
border: 1px solid rgba(201, 164, 90, 0.2); color: #fff;
padding: 6px; background: #303030;
display: flex; cursor: grab;
flex-direction: column;
gap: 6px;
background: rgba(16, 16, 16, 0.6);
} }
.priority-main { .priority-item:active {
display: flex; cursor: grabbing;
opacity: 0.8;
}
.priority_info {
display: grid;
height: 21px;
grid-template-columns: 24px minmax(0, 1fr) 24px;
align-items: center; align-items: center;
gap: 6px;
} }
.priority-name { .drag-handle {
font-size: 0.78rem; font-size: 18px;
} }
.priority-actions { .help-button {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.priority-help {
position: relative; position: relative;
display: inline-flex; width: 24px;
align-items: center; height: 22.375px;
justify-content: center; padding: 0 3.5px;
width: 18px; color: #fff;
height: 18px; background: #444;
font-size: 0.7rem; border: 1px solid #3d3d3d;
border-radius: 999px; border-radius: 3px;
border: 1px solid rgba(201, 164, 90, 0.4); font-size: 12.25px;
cursor: default; line-height: 18.375px;
cursor: pointer;
} }
.priority-help::after { .help-button::after {
content: attr(data-text);
position: absolute; position: absolute;
bottom: 125%; right: 0;
left: 50%; bottom: calc(100% + 5px);
transform: translateX(-50%); z-index: 10;
background: rgba(16, 16, 16, 0.9); width: 300px;
border: 1px solid rgba(201, 164, 90, 0.4); padding: 7px;
padding: 8px; color: #fff;
font-size: 0.7rem; background: #111;
width: 220px; border: 1px solid #777;
border-radius: 4px;
content: attr(data-text);
font-size: 12px;
line-height: 18px;
text-align: left;
white-space: pre-line; white-space: pre-line;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 0.2s ease; transition: opacity 0.15s ease;
z-index: 10;
} }
.priority-help:hover::after { .help-button:hover::after,
.help-button:focus-visible::after {
opacity: 1; opacity: 1;
} }
@media (max-width: 1024px) { .priority-control {
.npc-page { margin-top: 0;
padding: 16px; }
@media (max-width: 991px) {
.form_list,
.priority-sections {
grid-template-columns: 1fr;
}
.half_section_left {
border-right: 0;
} }
} }
</style> </style>
@@ -108,6 +108,7 @@ onMounted(() => {
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink> <RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink> <RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink> <RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<button class="ghost" @click="refreshPublicData">새로고침</button> <button class="ghost" @click="refreshPublicData">새로고침</button>
</div> </div>
</header> </header>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const load = async () => {
if (loading.value) {
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await trpc.public.getTraffic.query();
} catch (error) {
// Preserve the last successful graph if a later refresh fails.
errorMessage.value = getErrorMessage(error);
} finally {
loading.value = false;
}
};
const refreshRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.refresh,
width: Math.round((entry.refresh / Math.max(1, data.value?.maxRefresh ?? 1)) * 1_000) / 10,
}))
);
const onlineRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.online,
width: Math.round((entry.online / Math.max(1, data.value?.maxOnline ?? 1)) * 1_000) / 10,
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
Math.floor((Math.max(0, Math.min(100, value)) * 255) / 100)
.toString(16)
.padStart(2, '0');
return `#${channel(percentage)}00${channel(100 - percentage)}`;
};
onMounted(() => {
void load();
});
</script>
<template>
<main id="traffic-container" class="traffic-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="traffic-error" role="alert">{{ errorMessage }}</div>
<div v-if="loading && !data" class="traffic-loading">불러오는 중...</div>
<section v-if="data" class="chart-layout">
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in refreshRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxRefresh }}</td></tr>
</tbody>
</table>
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in onlineRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxOnline }}</td></tr>
</tbody>
</table>
</section>
<table v-if="data" class="legacy-table suspect-table legacy-bg0">
<thead>
<tr>
<th colspan="3" class="legacy-bg2 chart-title"> (순간과도갱신)</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in data.suspects" :key="entry.generalId ?? 'total'">
<td class="suspect-name">{{ entry.name }}</td>
<td class="suspect-score">{{ entry.refreshScoreTotal }}({{ entry.refresh }})</td>
<td class="little-bar-cell">
<div
v-if="entry.refresh > 0"
class="little-bar"
:style="{
width: `${Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10}%`,
backgroundColor: trafficColor(
Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10
),
}"
></div>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr><td><RouterLink class="legacy-close" to="/">돌아가기</RouterLink></td></tr>
<tr><td class="banner">SAMMO</td></tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.traffic-page {
width: 1016px;
min-width: 1016px;
min-height: 100vh;
margin: 0 auto;
padding: 0;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: normal;
}
.legacy-table {
border-collapse: collapse;
padding: 0;
color: #fff;
font-size: 14px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid gray;
padding: 0;
text-align: center;
font-weight: 400;
}
.legacy-bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.legacy-bg1 {
background-color: #423226;
background-image: url('/image/game/back_sandal.jpg');
}
.legacy-bg2 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.title-table,
.footer-table {
width: 1000px;
margin: 0 auto;
}
.title-table {
margin-bottom: 18px;
}
.title-table td {
height: 54px;
}
.chart-layout {
width: 1016px;
display: flex;
gap: 26px;
align-items: flex-start;
box-sizing: border-box;
padding: 0 12px;
}
.chart-table {
width: 483px;
table-layout: fixed;
}
.chart-title {
height: 34px;
font-size: 24px;
}
.chart-row {
height: 30px;
}
.period {
width: 100px;
}
.time {
width: 60px;
}
.separator {
width: 2px;
}
.bar-cell {
width: 320px;
text-align: left !important;
white-space: nowrap;
}
.big-bar {
float: left;
position: relative;
height: 30px;
}
.big-bar span {
float: right;
padding-right: 1ch;
line-height: 30px;
}
.out-bar {
line-height: 30px;
margin-left: 1ch;
}
.spacer {
height: 5px;
}
.record {
height: 30px;
}
.suspect-table {
margin: 18px auto;
}
.suspect-name,
.suspect-score {
width: 98px;
}
.little-bar-cell {
width: 798px;
text-align: left !important;
}
.little-bar {
height: 17px;
}
.footer-table {
margin-top: 18px;
}
.banner {
height: 24px;
}
.legacy-close {
color: #fff;
text-decoration: underline;
}
.traffic-error,
.traffic-loading {
width: 1000px;
margin: -12px auto 12px;
border: 1px solid gray;
padding: 6px;
text-align: center;
background: #302016;
}
.traffic-error {
color: #ff8080;
}
</style>
+1
View File
@@ -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,
}, },
}); });
+3 -1
View File
@@ -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
@@ -30,6 +30,14 @@ turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴
`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기 `last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기
초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을 초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을
ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다. ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다.
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
비교한다.
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
않아 동적 호환 판정에서 제외한다.
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료 나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로 기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로
올리지 않는다. 올리지 않는다.
+2 -2
View File
@@ -49,7 +49,7 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Define gateway login handoff + profile selection flow for the game frontend (token delivery, auto-login, cookie vs localStorage policy). - [AI suggestion] Define gateway login handoff + profile selection flow for the game frontend (token delivery, auto-login, cookie vs localStorage policy).
- [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰. - [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰.
- [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록). - [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록).
- [AI suggestion] Port legacy main UI components into `app/game-frontend` (MapViewer, CommandSelectForm, MessagePanel 등). - [AI suggestion] Port remaining legacy main UI components into `app/game-frontend` (MessagePanel 이관 완료; 나머지 패널 추적).
- [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout. - [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout.
- [AI suggestion] Implement join/빙의 UI and post-creation refresh flow. - [AI suggestion] Implement join/빙의 UI and post-creation refresh flow.
- [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements. - [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements.
@@ -57,7 +57,7 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Wire `realtimeEnabled` to an SSE or polling channel and update main dashboard data buckets (map/lobby/messages/commands). - [AI suggestion] Wire `realtimeEnabled` to an SSE or polling channel and update main dashboard data buckets (map/lobby/messages/commands).
- [AI suggestion] Finalize static asset and web base URLs (`VITE_GAME_WEB_URL`, `VITE_GAME_ASSET_URL`) and document deployment mapping for legacy images. - [AI suggestion] Finalize static asset and web base URLs (`VITE_GAME_WEB_URL`, `VITE_GAME_ASSET_URL`) and document deployment mapping for legacy images.
- [AI suggestion] Expand Join UI to cover inherit options (특기/도시/턴타임/보너스 스탯) using `join.getConfig` and `join.createGeneral` inputs. - [AI suggestion] Expand Join UI to cover inherit options (특기/도시/턴타임/보너스 스탯) using `join.getConfig` and `join.createGeneral` inputs.
- [AI suggestion] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads. - [x] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads.
- [AI suggestion] Port legacy TipTap-based editors (국가 방침/임관 권유) into game-frontend and reuse the new board image upload policy. - [AI suggestion] Port legacy TipTap-based editors (국가 방침/임관 권유) into game-frontend and reuse the new board image upload policy.
## Runtime and Operations (Lower Priority) ## Runtime and Operations (Lower Priority)
@@ -210,6 +210,17 @@ Three post-required cooldown cases project the legacy `next_execute` KV and
core general meta into the same world-level cooldown record. They cover the core general meta into the same world-level cooldown record. They cover the
stored `current + 60 - preReq` value, rejection one turn before availability, stored `current + 60 - preReq` value, rejection one turn before availability,
and successful execution exactly at the boundary. and successful execution exactly at the boundary.
Four missing-target cases cover nonexistent general IDs for gift and
employment plus nonexistent city IDs for spying and movement. Both engines
reject the requested command, execute rest without command RNG, and produce
the same semantic state delta.
Thirteen resource argument and balance cases cover 100-unit rounding and
minimum/maximum clamps for gift, donation, and rice trade; donation against
available and minimum resources; and gift reserve and self-target rejection.
They compare normalized last-turn arguments, RNG, fallback, and semantic state
deltas.
Requests that omit the required argument object remain unverified because the
reference runner did not terminate within the bounded comparison run.
This is not yet a claim that every command-specific This is not yet a claim that every command-specific
constraint, clamp and persistence boundary has been dynamically constraint, clamp and persistence boundary has been dynamically
compared. compared.
+70 -16
View File
@@ -19,6 +19,11 @@ tree instead of replacing images with layout-neutral placeholders.
NPC list, including mutations and recoverable API failures. NPC list, including mutations and recoverable API failures.
`tournament-betting.spec.ts` covers the separate tournament and tournament `tournament-betting.spec.ts` covers the separate tournament and tournament
betting routes, including a recoverable failed bet. betting routes, including a recoverable failed bet.
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
명예의 전당 computed DOM without embedding the reference password.
`battleSimulator.spec.ts` covers the authenticated simulator with and without
an owned general. `battleSimulatorRef.spec.ts` can additionally exercise the
live reference page when its URL, user, and ignored password file are supplied.
Run the suite from the core2026 repository root: Run the suite from the core2026 repository root:
@@ -31,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/`
@@ -43,22 +58,27 @@ storage, route guards, and image loading.
## Enforced contracts ## Enforced contracts
| Screen | Ref entry point | Current automated contract | | Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | | gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | | gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | | gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| 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 |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | | 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 |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | | 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 |
| 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 | | 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 |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | | yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| 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 | | 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 personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | | 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 finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | | public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus | | 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 |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | | nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, fixed-seed result/logs, retained input after API error |
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
The global game baseline is black, white, Pretendard 14px. Legacy texture The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
@@ -85,6 +105,16 @@ Adding or changing a frontend route requires:
Pixel snapshots may be added after these structural assertions pass. Dynamic Pixel snapshots may be added after these structural assertions pass. Dynamic
regions must not be hidden merely to make a pixel threshold pass. regions must not be hidden merely to make a pixel threshold pass.
To refresh the PHP ranking evidence after building the ignored reference
webpack assets, run:
```sh
REF_RANKING_URL=http://127.0.0.1:3400/sam/ \
REF_RANKING_PASSWORD_FILE=/path/to/ignored/user1_password \
REF_RANKING_ARTIFACT_DIR=/path/to/ignored/artifacts \
node tools/frontend-legacy-parity/reference-rankings.mjs
```
The nation office suite can be run independently: The nation office suite can be run independently:
```sh ```sh
@@ -96,6 +126,30 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs
records desktop/500px computed DOM and screenshots from the PHP service without records desktop/500px computed DOM and screenshots from the PHP service without
changing its product code. changing its product code.
The NPC policy suite and its reference collector can be run independently:
```sh
PLAYWRIGHT_FRONTEND_PORT=15126 \
pnpm --filter @sammo-ts/game-frontend test:e2e:npc-policy
REF_PARITY_USER=refuser1 \
REF_PARITY_PASSWORD_FILE=/path/to/password-file \
REF_PARITY_BASE_URL=http://127.0.0.1:3400/sam/ \
node tools/frontend-legacy-parity/reference-npc-policy.mjs
```
The collector writes desktop and 500px screenshots plus computed DOM JSON only
when `REF_PARITY_ARTIFACT_DIR` is set. It requires an existing reference general
owned by the supplied account and never accepts a password on the command line.
The current-city reference can be collected independently with
`tools/frontend-legacy-parity/reference-current-city.mjs`. It requires
`REF_PARITY_PASSWORD_FILE` and accepts `REF_PARITY_URL`, `REF_PARITY_USER`, and
`REF_PARITY_ARTIFACT_DIR`; the password is read from the ignored secret file
and is never written to the artifact. The matching core fixture is
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
For a review run that also writes full-page screenshots, create an ignored For a review run that also writes full-page screenshots, create an ignored
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
suite. The ordinary CI run does not write screenshots after successful tests. suite. The ordinary CI run does not write screenshots after successful tests.
@@ -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));
};
+10 -11
View File
@@ -7,8 +7,8 @@ export type InheritBuffType =
| 'warAvoidRatio' | 'warAvoidRatio'
| 'warCriticalRatio' | 'warCriticalRatio'
| 'warMagicTrialProb' | 'warMagicTrialProb'
| 'success' | 'domesticSuccessProb'
| 'fail' | 'domesticFailProb'
| 'warAvoidRatioOppose' | 'warAvoidRatioOppose'
| 'warCriticalRatioOppose' | 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose'; | 'warMagicTrialProbOppose';
@@ -25,7 +25,8 @@ const DOMESTIC_TARGETS = new Set<TriggerDomesticActionType>([
]); ]);
const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => { const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => {
const raw = buff[key]; const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
const raw = buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : undefined);
if (typeof raw !== 'number' || !Number.isFinite(raw)) { if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return 0; return 0;
} }
@@ -40,7 +41,9 @@ const parseInheritBuff = (value: unknown): Record<string, unknown> => {
return asRecord(value); return asRecord(value);
}; };
const resolveBuffRecord = (context: { general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } } }): Record<string, unknown> => { const resolveBuffRecord = (context: {
general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } };
}): Record<string, unknown> => {
const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff); const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff);
if (Object.keys(fromTrigger).length > 0) { if (Object.keys(fromTrigger).length > 0) {
return fromTrigger; return fromTrigger;
@@ -58,11 +61,11 @@ const applyDomesticBuff = (
return value; return value;
} }
if (varType === 'success') { if (varType === 'success') {
const level = readBuffLevel(buff, 'success'); const level = readBuffLevel(buff, 'domesticSuccessProb');
return value + level * 0.01; return value + level * 0.01;
} }
if (varType === 'fail') { if (varType === 'fail') {
const level = readBuffLevel(buff, 'fail'); const level = readBuffLevel(buff, 'domesticFailProb');
return value - level * 0.01; return value - level * 0.01;
} }
return value; return value;
@@ -84,11 +87,7 @@ const applyWarBuff = (buff: Record<string, unknown>, statName: WarStatName, valu
return value; return value;
}; };
const applyOpposeWarBuff = ( const applyOpposeWarBuff = (buff: Record<string, unknown>, statName: WarStatName, value: number | [number, number]) => {
buff: Record<string, unknown>,
statName: WarStatName,
value: number | [number, number]
) => {
if (typeof value !== 'number') { if (typeof value !== 'number') {
return value; return value;
} }
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import type { General } from '../src/domain/entities.js';
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
id: 1,
name: 'Tester',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 0,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 0,
rice: 0,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 20,
npcState: 0,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) },
});
describe('inheritance buff legacy keys', () => {
it('applies the canonical legacy domestic buff names', () => {
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
const context = {
general: buildGeneral({
domesticSuccessProb: 3,
domesticFailProb: 2,
}),
};
expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53);
expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18);
});
it('continues to read the earlier core success and fail aliases', () => {
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
const context = {
general: buildGeneral({
success: 2,
fail: 1,
}),
};
expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52);
expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19);
});
});
@@ -75,6 +75,82 @@ export const canonicalFrontendFixture = {
[2, '진', '#1976d2', 2], [2, '진', '#1976d2', 2],
], ],
}, },
bestGeneral: {
isUnited: true,
sections: [
{
title: '명 성',
valueType: 'int',
entries: [
{
id: 1,
name: '유비',
ownerName: '시각검증',
nationName: '촉',
bgColor: '#006400',
fgColor: '#ffffff',
picture: 'default.jpg',
imageServer: 0,
value: 12000,
printValue: '12,000',
},
{
id: 2,
name: '조조',
ownerName: '검증계정',
nationName: '위',
bgColor: '#8b0000',
fgColor: '#ffffff',
picture: 'default.jpg',
imageServer: 0,
value: 11000,
printValue: '11,000',
},
],
},
{
title: '계 급',
valueType: 'int',
entries: [],
},
],
uniqueItems: [
{
title: '명 마',
slot: 'horse',
entries: [
{
itemKey: 'che_명마_15_적토마',
itemName: '적토마',
itemInfo: '최고의 명마',
owner: {
id: 1,
name: '유비',
nationName: '촉',
bgColor: '#006400',
fgColor: '#ffffff',
picture: 'default.jpg',
imageServer: 0,
},
},
{
itemKey: 'che_명마_15_적토마',
itemName: '적토마',
itemInfo: '최고의 명마',
owner: {
id: 0,
name: '경매중',
nationName: '-',
bgColor: '#00582c',
fgColor: '#ffffff',
picture: null,
imageServer: 0,
},
},
],
},
],
},
hallOptions: [ hallOptions: [
{ {
season: 1, season: 1,
@@ -0,0 +1,384 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
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 pathname = new URL(route.request().url()).pathname;
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const general = {
id: 1,
name: '테스트장수',
npcState: 0,
nationId: 1,
cityId: 1,
troopId: 0,
picture: 'default.jpg',
imageServer: 0,
officerLevel: 1,
stats: { leadership: 80, strength: 70, intelligence: 90 },
gold: 1000,
rice: 1000,
crew: 500,
train: 100,
atmos: 100,
injury: 0,
experience: 1200,
dedication: 900,
items: { horse: null, weapon: null, book: null, item: null },
};
const generalContext = {
general,
city: {
id: 1,
name: '낙양',
level: 7,
nationId: 1,
population: 50000,
agriculture: 5000,
commerce: 5000,
security: 5000,
defence: 5000,
wall: 5000,
supplyState: 1,
frontState: 2,
},
nation: {
id: 1,
name: '테스트국',
color: '#d32f2f',
level: 5,
gold: 10000,
rice: 10000,
tech: 1200,
typeCode: 'che_군벌',
capitalCityId: 1,
},
settings: {},
penalties: {},
};
const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({
generalId,
generalName,
nationId,
nationName,
color,
icon: '/image/icons/default.jpg',
});
const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
const buildMessages = (permission: number) => ({
result: true,
public: [
{
id: 101,
msgType: 'public',
src: ownTarget,
dest: null,
text: '전체 메시지 본문',
option: {},
time: messageTime,
},
],
national: [
{
id: 102,
msgType: 'national',
src: ownTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: '국가 메시지 본문',
option: {},
time: messageTime,
},
],
private: [
{
id: 103,
msgType: 'private',
src: foreignTarget,
dest: ownTarget,
text: '개인 메시지 본문',
option: {},
time: messageTime,
},
],
diplomacy: [
{
id: 104,
msgType: 'diplomacy',
src: foreignTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)',
option:
permission >= 3
? { action: 'noAggression', deletable: false }
: { action: 'noAggression', deletable: false, invalid: true },
time: messageTime,
},
],
sequence: 104,
nationId: 1,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: { private: 0, diplomacy: 0 },
});
const contacts = {
nation: [
{
nationId: 0,
mailbox: 9000,
name: '재야',
color: '#000000',
general: [],
},
{
nationId: 1,
mailbox: 9001,
name: '테스트국',
color: '#d32f2f',
general: [
[1, '테스트장수', 4],
[2, '아군군주', 1],
],
},
{
nationId: 2,
mailbox: 9002,
name: '상대국',
color: '#2457a6',
general: [
[8, '상대외교관', 4],
[9, '상대일반', 0],
],
},
],
};
const installFixture = async (
page: Page,
options: { permission: number; sendError?: string }
): Promise<Array<{ operation: string; body: unknown }>> => {
const mutations: Array<{ operation: string; body: unknown }> = [];
await page.addInitScript(
({ gameToken, profile }) => {
window.localStorage.setItem('sammo-game-token', gameToken);
window.localStorage.setItem('sammo-game-profile', profile);
},
{
gameToken: fixture.game.session.gameToken,
profile: fixture.game.session.profile,
}
);
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
await page.route('**/che/api/events**', (route) => route.abort());
await page.route('**/che/api/trpc/**', async (route) => {
const body = route.request().postDataJSON();
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') {
return response({ ...fixture.game.lobby, myGeneral: general });
}
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
if (operation === 'world.getMap') {
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response([]);
}
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
if (operation === 'messages.getContacts') return response(contacts);
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (
operation === 'messages.send' ||
operation === 'messages.readLatest' ||
operation === 'messages.delete' ||
operation === 'messages.respond'
) {
mutations.push({ operation, body });
if (operation === 'messages.send' && options.sendError) {
return errorResponse(operation, options.sendError);
}
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
}
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return mutations;
};
const openMessages = async (page: Page, viewport: { width: number; height: number }) => {
await page.setViewportSize(viewport);
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
if (viewport.width <= 1024) {
await page.getByRole('button', { name: '메시지', exact: true }).click();
}
await expect(page.locator('.MessagePanel')).toBeVisible();
};
for (const viewport of [
{ width: 1000, height: 900 },
{ width: 500, height: 900 },
]) {
test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => {
await installFixture(page, { permission: 4 });
await openMessages(page, viewport);
const geometry = await page.locator('.MessagePanel').evaluate((panel) => {
const required = (selector: string) => panel.querySelector<HTMLElement>(selector)!;
const rect = (element: Element) => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
};
const panelStyle = getComputedStyle(panel);
const header = required('.BoardHeader');
const plate = required('.msg-plate');
const icon = required('.general-icon');
return {
panel: rect(panel),
inputForm: rect(required('.MessageInputForm')),
select: rect(required('.message-select')),
input: rect(required('.message-text')),
submit: rect(required('.message-send')),
publicSection: rect(required('.PublicTalk')),
nationalSection: rect(required('.NationalTalk')),
firstHeader: rect(header),
firstPlate: rect(plate),
firstIcon: rect(icon),
computed: {
panelDisplay: panelStyle.display,
panelColumns: panelStyle.gridTemplateColumns,
panelFontSize: panelStyle.fontSize,
headerColor: getComputedStyle(header).color,
headerOutlineWidth: getComputedStyle(header).outlineWidth,
plateBackgroundColor: getComputedStyle(plate).backgroundColor,
plateFontSize: getComputedStyle(plate).fontSize,
plateMinHeight: getComputedStyle(plate).minHeight,
iconObjectFit: getComputedStyle(icon).objectFit,
},
};
});
expect(geometry.panel.x).toBeCloseTo(0, 0);
expect(geometry.panel.width).toBeCloseTo(viewport.width, 0);
expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0);
expect(geometry.select.height).toBeCloseTo(35.5, 0);
expect(geometry.submit.height).toBeCloseTo(35.5, 0);
expect(geometry.firstHeader.height).toBeCloseTo(25, 0);
expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64);
expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 });
expect(geometry.computed).toMatchObject({
panelFontSize: '14px',
headerColor: 'rgb(255, 255, 255)',
headerOutlineWidth: '1px',
plateBackgroundColor: 'rgb(20, 28, 101)',
plateFontSize: '12.5px',
plateMinHeight: '64px',
iconObjectFit: 'fill',
});
if (viewport.width === 1000) {
expect(geometry.computed.panelDisplay).toBe('grid');
expect(geometry.computed.panelColumns).toBe('500px 500px');
expect(geometry.select.width).toBeCloseTo(166.66, 0);
expect(geometry.input.width).toBeCloseTo(666.66, 0);
expect(geometry.submit.width).toBeCloseTo(166.66, 0);
expect(geometry.publicSection.width).toBeCloseTo(500, 0);
expect(geometry.nationalSection.x).toBeCloseTo(500, 0);
} else {
expect(geometry.computed.panelDisplay).toBe('block');
expect(geometry.select.width).toBeCloseTo(250, 0);
expect(geometry.input.width).toBeCloseTo(500, 0);
expect(geometry.input.height).toBeCloseTo(33.5, 0);
expect(geometry.submit.width).toBeCloseTo(250, 0);
}
const submit = page.locator('.message-send');
await submit.hover();
expect(
await submit.evaluate((element) => ({
cursor: getComputedStyle(element).cursor,
backgroundColor: getComputedStyle(element).backgroundColor,
}))
).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' });
await submit.focus();
expect(
await submit.evaluate((element) => ({
outlineWidth: getComputedStyle(element).outlineWidth,
boxShadow: getComputedStyle(element).boxShadow,
}))
).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
});
}
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => {
const mutations = await installFixture(page, { permission: 4 });
await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9002"]')).toHaveCount(1);
await expect(select.locator('option[value="8"]')).toBeDisabled();
await expect(select.locator('option[value="9"]')).toBeEnabled();
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click();
await expect(select).toHaveValue('8');
await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1);
const deleteButton = page.locator('.PublicTalk .delete-message');
page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('전송 성공');
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
});
test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => {
const mutations = await installFixture(page, {
permission: 2,
sendError: '공개 메세지를 보낼 수 없습니다.',
});
await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('차단될 메시지');
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect(page.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.');
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
});
@@ -0,0 +1,238 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { dirname, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const imageRoot = resolve(repositoryRoot, '../../image');
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
const response = (data: unknown) => ({ result: { data } });
const operations = (route: Route): string[] => {
const pathname = new URL(route.request().url()).pathname;
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installImages = async (page: Page): Promise<void> => {
await page.route('**/image/**', async (route) => {
const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, '');
for (const candidate of [
resolve(imageRoot, relative),
resolve(imageRoot, 'game', relative),
resolve(imageRoot, 'icons', '22.jpg'),
]) {
try {
const body = await readFile(candidate);
await route.fulfill({
status: 200,
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
body,
});
return;
} catch {
// 다음 공개 image root 후보를 확인한다.
}
}
await route.abort('failed');
});
};
const statusFixture = {
items: {
previous: 12_000,
lived_month: 240,
max_domestic_critical: 80,
active_action: 35,
combat: 150,
sabotage: 60,
dex: 42,
unifier: 0,
tournament: 30,
betting: 20,
max_belong: 8,
},
totalPoint: 12_665,
inheritConst: {
minMonthToAllowInheritItem: 4,
inheritBornSpecialPoint: 6000,
inheritBornTurntimePoint: 2500,
inheritBornCityPoint: 1000,
inheritBornStatPoint: 1000,
inheritItemUniqueMinPoint: 5000,
inheritItemRandomPoint: 3000,
inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000],
inheritSpecificSpecialPoint: 4000,
inheritResetAttrPointBase: [1000, 1000, 2000, 3000],
inheritCheckOwnerPoint: 1000,
},
buffLevels: {
warAvoidRatio: 0,
warCriticalRatio: 1,
warMagicTrialProb: 0,
domesticSuccessProb: 0,
domesticFailProb: 0,
warAvoidRatioOppose: 0,
warCriticalRatioOppose: 0,
warMagicTrialProbOppose: 0,
},
resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 },
resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 },
availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }],
availableUnique: [
{
key: 'che_무기_12_칠성검',
name: '칠성검(+12)',
rawName: '칠성검',
info: '무력을 올려주는 유니크 무기입니다.',
},
],
availableTargetGenerals: [{ id: 8, name: '조조' }],
turnTimeZones: ['00:00'],
isUnited: false,
currentSpecialWar: 'che_선봉',
currentStat: { leadership: 70, strength: 45, intel: 85 },
};
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
let buffMutationCount = 0;
await installImages(page);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
window.localStorage.setItem('sammo-game-profile', 'che');
});
await page.route('**/che/api/trpc/**', async (route) => {
const names = operations(route);
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }),
});
return;
}
const result = names.map((name) => {
if (name === 'inherit.getStatus') return response(statusFixture);
if (name === 'lobby.info') {
return response({
profile: { id: 'che', scenario: 'default', name: '체섭' },
world: { year: 200, month: 4 },
myGeneral: { id: 7, name: '유비', nationId: 1 },
});
}
if (name === 'inherit.getLogs') {
return response([
{
id: 2,
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
createdAt: '2026-07-26T00:00:00.000Z',
},
]);
}
if (name === 'join.getConfig') {
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
}
if (name === 'inherit.buyHiddenBuff') {
buffMutationCount += 1;
return response({ ok: true, remainPoint: 11_800 });
}
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(result),
});
});
return { buffMutationCount: () => buffMutationCount };
};
test.describe('inheritance management legacy parity', () => {
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
await installFixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(gameUrl);
await expect(page.locator('#container')).toBeVisible();
await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검');
const desktop = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: box.x, width: box.width };
};
const container = getComputedStyle(document.querySelector<HTMLElement>('#container')!);
const title = getComputedStyle(document.querySelector<HTMLElement>('.section-title')!);
const button = getComputedStyle(document.querySelector<HTMLElement>('.buy-button')!);
return {
container: rect('#container'),
firstPoint: rect('#inherit_sum'),
fontFamily: container.fontFamily,
fontSize: container.fontSize,
backgroundImage: container.backgroundImage,
titleBackgroundImage: title.backgroundImage,
buttonBackground: button.backgroundColor,
};
});
expect(desktop.container.width).toBe(1000);
expect(desktop.container.x).toBe(140);
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
expect(desktop.fontFamily).toContain('Pretendard');
expect(desktop.fontSize).toBe('14px');
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
expect(desktop.titleBackgroundImage).toContain('back_green.jpg');
const buyButton = page.locator('.buy-button').first();
const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
await buyButton.hover();
const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(afterHover).not.toBe(beforeHover);
await buyButton.focus();
await expect(buyButton).toBeFocused();
if (artifactRoot) {
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true });
}
await page.setViewportSize({ width: 500, height: 900 });
await page.reload();
await expect(page.locator('#container')).toBeVisible();
const mobile = await page.evaluate(() => {
const container = document.querySelector<HTMLElement>('#container')!.getBoundingClientRect();
const first = document.querySelector<HTMLElement>('#inherit_sum')!.getBoundingClientRect();
const second = document.querySelector<HTMLElement>('#inherit_previous')!.getBoundingClientRect();
return {
containerWidth: container.width,
firstWidth: first.width,
stacked: second.y > first.y,
};
});
expect(mobile.containerWidth).toBe(500);
expect(mobile.firstWidth).toBeCloseTo(482, 0);
expect(mobile.stacked).toBe(true);
});
test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => {
const fixture = await installFixture(page);
page.on('dialog', (dialog) => dialog.accept());
await page.goto(gameUrl);
await page.locator('#buff-warAvoidRatio').fill('1');
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
await expect.poll(fixture.buffMutationCount).toBe(1);
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
});
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
await installFixture(page, { failBuff: true });
page.on('dialog', (dialog) => dialog.accept());
await page.goto(gameUrl);
await page.locator('#buff-warAvoidRatio').fill('1');
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
await expect(page.locator('[role="alert"]')).toBeVisible();
await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1');
await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled();
});
});
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
const response = (data: unknown) => ({ result: { data } }); const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => { const operationNames = (route: Route): string[] => {
@@ -90,6 +91,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
sequence: visible ? diplomacyMessage.id : -1, sequence: visible ? diplomacyMessage.id : -1,
nationId: 1, nationId: 1,
generalName: general.name, generalName: general.name,
permission: canRespondDiplomacy ? 4 : 2,
canRespondDiplomacy, canRespondDiplomacy,
latestRead: { diplomacy: 0, private: 0 }, latestRead: { diplomacy: 0, private: 0 },
}); });
@@ -131,6 +133,9 @@ const installFixture = async (
if (operation === 'messages.getRecent') { if (operation === 'messages.getRecent') {
return response(messageBundle(visible, options.canRespondDiplomacy)); return response(messageBundle(visible, options.canRespondDiplomacy));
} }
if (operation === 'messages.getContacts') return response({ nation: [] });
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'messages.respond') { if (operation === 'messages.respond') {
mutations.push({ operation, body: requestBody }); mutations.push({ operation, body: requestBody });
if (options.acceptResponse) { if (options.acceptResponse) {
@@ -151,9 +156,9 @@ const installFixture = async (
}; };
const openDiplomacyTab = async (page: Page) => { const openDiplomacyTab = async (page: Page) => {
await page.goto('http://127.0.0.1:15102/che/'); await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
await page.getByRole('button', { name: '외교', exact: true }).last().click(); await expect(page.locator('.DiplomacyTalk')).toBeVisible();
await expect(page.getByText(diplomacyMessage.text)).toBeVisible(); await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
}; };
@@ -186,16 +191,16 @@ test.describe('instant diplomacy response UI', () => {
}); });
expect(geometry.buttons).toHaveLength(2); expect(geometry.buttons).toHaveLength(2);
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0); expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(0, 0);
expect(geometry.buttons[0]).toMatchObject({ expect(geometry.buttons[0]).toMatchObject({
color: 'rgb(143, 209, 143)', color: 'rgb(255, 255, 255)',
fontSize: '11.2px', fontSize: '12.5px',
borderWidth: '1px', borderWidth: '1px',
cursor: 'pointer', cursor: 'pointer',
}); });
expect(geometry.buttons[1]).toMatchObject({ expect(geometry.buttons[1]).toMatchObject({
color: 'rgb(224, 154, 154)', color: 'rgb(255, 255, 255)',
fontSize: '11.2px', fontSize: '12.5px',
borderWidth: '1px', borderWidth: '1px',
cursor: 'pointer', cursor: 'pointer',
}); });
@@ -234,18 +239,17 @@ test.describe('instant diplomacy response UI', () => {
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => { test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
const mutations = await installFixture(page, { acceptResponse: false }); const mutations = await installFixture(page, { acceptResponse: false });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('http://127.0.0.1:15102/che/'); await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
await page.getByRole('button', { name: '메시지', exact: true }).click(); await page.getByRole('button', { name: '메시지', exact: true }).click();
await page.getByRole('button', { name: '외교', exact: true }).click();
const responseRow = page.locator('.message-response'); const responseRow = page.locator('.message-response');
await expect(responseRow).toBeVisible(); await expect(responseRow).toBeVisible();
const itemWidth = await page const itemWidth = await page
.locator('.message-item') .locator('.DiplomacyTalk .msg-plate')
.evaluate((element) => element.getBoundingClientRect().width); .evaluate((element) => element.getBoundingClientRect().width);
expect(itemWidth).toBeGreaterThan(320); expect(itemWidth).toBeGreaterThanOrEqual(389);
expect(itemWidth).toBeLessThanOrEqual(342); expect(itemWidth).toBeLessThanOrEqual(390);
page.once('dialog', async (dialog) => { page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('거절하시겠습니까?'); expect(dialog.message()).toBe('거절하시겠습니까?');
@@ -272,10 +276,9 @@ test.describe('instant diplomacy response UI', () => {
canRespondDiplomacy: false, canRespondDiplomacy: false,
}); });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('http://127.0.0.1:15102/che/'); await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
await page.getByRole('button', { name: '메시지', exact: true }).click(); await page.getByRole('button', { name: '메시지', exact: true }).click();
await page.getByRole('button', { name: '외교', exact: true }).click();
const accept = page.locator('.message-response').getByRole('button', { name: '수락' }); const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
await expect(accept).toBeDisabled(); await expect(accept).toBeDisabled();
@@ -284,7 +287,7 @@ test.describe('instant diplomacy response UI', () => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
return { cursor: style.cursor, opacity: style.opacity }; return { cursor: style.cursor, opacity: style.opacity };
}) })
).toEqual({ cursor: 'not-allowed', opacity: '0.5' }); ).toEqual({ cursor: 'not-allowed', opacity: '0.65' });
await accept.click({ force: true }); await accept.click({ force: true });
expect(mutations).toHaveLength(0); expect(mutations).toHaveLength(0);
}); });
@@ -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();
}
@@ -13,8 +13,10 @@ export default defineConfig({
'visual-parity.spec.ts', 'visual-parity.spec.ts',
'public-gaps.spec.ts', 'public-gaps.spec.ts',
'instant-diplomacy-message.spec.ts', 'instant-diplomacy-message.spec.ts',
'ingame-message-parity.spec.ts',
'tournament-betting.spec.ts', 'tournament-betting.spec.ts',
'dynasty-parity.spec.ts', 'dynasty-parity.spec.ts',
'inheritance-management.spec.ts',
], ],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
@@ -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();
@@ -0,0 +1,128 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_PARITY_USER ?? 'refadmin';
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-current-city');
if (!passwordFile) {
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const loginResult = await loginResponse.json();
if (!loginResponse.ok() || loginResult.result !== true) {
throw new Error('Reference login failed.');
}
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
const mapCity = page.locator('a[href*="b_currentCity.php?citylist="]').first();
const hasMapCity = await mapCity.isVisible({ timeout: 8_000 }).catch(() => false);
let mapInteraction;
if (hasMapCity) {
mapInteraction = await mapCity.evaluate((element) => ({
available: true,
href: element.getAttribute('href'),
cursor: getComputedStyle(element).cursor,
rect: (() => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
})(),
}));
await mapCity.click();
await page.waitForLoadState('networkidle');
if (!page.url().includes('b_currentCity.php?citylist=')) {
throw new Error(`Reference map click did not open current city: ${page.url()}`);
}
} else {
mapInteraction = {
available: false,
pageUrl: page.url(),
pageTitle: await page.title(),
};
await page.goto(new URL('hwe/b_currentCity.php', baseUrl).toString(), { waitUntil: 'networkidle' });
}
const measurements = await page.evaluate(() => {
const measure = (element) => {
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderCollapse: style.borderCollapse,
padding: style.padding,
textAlign: style.textAlign,
},
};
};
const tables = [...document.querySelectorAll('table')];
const selector = document.querySelector('#citySelector');
const stats = tables.find((table) => table.textContent?.includes('90병장'));
const generals = document.querySelector('#general_list')?.closest('table');
const firstIcon = document.querySelector('.generalIcon');
const title = stats?.querySelector('tr:first-child td');
return {
body: measure(document.body),
tables: tables.map(measure),
selector: selector ? measure(selector) : null,
stats: stats ? measure(stats) : null,
generals: generals ? measure(generals) : null,
firstIcon: firstIcon
? {
...measure(firstIcon),
naturalWidth: firstIcon.naturalWidth,
naturalHeight: firstIcon.naturalHeight,
}
: null,
title: title ? measure(title) : null,
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
});
await page.screenshot({
path: resolve(artifactRoot, 'reference-current-city-desktop.png'),
fullPage: true,
animations: 'disabled',
});
await writeFile(
resolve(artifactRoot, 'reference-current-city-computed-dom.json'),
`${JSON.stringify({ mapInteraction, currentCity: measurements }, null, 2)}\n`
);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`);
await context.close();
} finally {
await browser.close();
}
@@ -0,0 +1,91 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_GENERAL_USER ?? 'refuser1';
const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-general-lists');
if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const measure = async (page, selectors) =>
page.evaluate((items) => {
const result = {};
for (const [name, selector] of Object.entries(items)) {
const element = document.querySelector(selector);
if (!element) {
result[name] = null;
continue;
}
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
result[name] = {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
borderCollapse: style.borderCollapse,
backgroundImage: style.backgroundImage,
color: style.color,
},
};
}
return { elements: result, documentWidth: document.documentElement.scrollWidth };
}, selectors);
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const salt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(salt + password + salt)
.digest('hex');
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const loginResult = await login.json();
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
const output = {};
for (const [name, path, selectors] of [
[
'generals',
'hwe/b_myGenInfo.php',
{
body: 'body',
title: 'body > table:first-of-type',
list: 'body > table:nth-of-type(2)',
firstRow: 'body > table:nth-of-type(2) tr:nth-child(2)',
},
],
[
'secret',
'hwe/b_genList.php',
{
body: 'body',
title: 'body > table:first-of-type',
summary: 'body > table:nth-of-type(2)',
list: '#general_list',
firstRow: '#general_list tbody tr:first-child',
},
],
]) {
await page.goto(new URL(path, baseUrl).toString(), { waitUntil: 'networkidle' });
output[name] = await measure(page, selectors);
await page.screenshot({ path: resolve(artifactRoot, `ref-${name}.png`), fullPage: true });
}
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
} finally {
await browser.close();
}
@@ -0,0 +1,175 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_MENU_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_MENU_USER ?? 'refuser1';
const passwordFile = process.env.REF_MENU_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_MENU_ARTIFACT_DIR ?? 'test-results/reference-ingame-menus');
if (!passwordFile) {
throw new Error('REF_MENU_PASSWORD_FILE is required.');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const login = async (context, page) => {
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
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 },
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
};
const rectAndStyle = (element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style: {
display: style.display,
gridTemplateColumns: style.gridTemplateColumns,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderTopColor: style.borderTopColor,
borderTopWidth: style.borderTopWidth,
padding: style.padding,
margin: style.margin,
cursor: style.cursor,
},
};
};
const measure = async (page, selectors) =>
page.evaluate(
({ selectors, measureSource }) => {
const measureElement = new Function(`return (${measureSource})`)();
const result = {};
for (const [name, selector] of Object.entries(selectors)) {
const element = document.querySelector(selector);
result[name] = element ? measureElement(element) : null;
}
return {
elements: result,
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
},
{ selectors, measureSource: rectAndStyle.toString() }
);
const browser = await chromium.launch({ headless: true });
try {
const output = {};
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const page = await context.newPage();
const consoleErrors = [];
const failedResources = [];
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('response', (response) => {
if (response.status() >= 400) failedResources.push(`${response.status()} ${response.url()}`);
});
await login(context, page);
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
await page.goto(new URL('hwe/b_myPage.php', baseUrl).toString(), { waitUntil: 'networkidle' });
await page.locator('#container').waitFor();
const myPage = await measure(page, {
body: 'body',
container: '#container',
title: '#container > .row:first-child',
infoColumn: '#container > .row:nth-child(2) > .col:first-child',
settingsColumn: '#container > .row:nth-child(2) > .col:nth-child(2)',
saveButton: '#set_my_setting',
firstSelect: 'select',
customCss: '#custom_css',
firstLogTitle: '#generalActionPlate',
});
await page.screenshot({ path: resolve(artifactRoot, `ref-my-page-${viewport.name}.png`), fullPage: true });
await page.goto(new URL('hwe/a_traffic.php', baseUrl).toString(), { waitUntil: 'networkidle' });
const traffic = await measure(page, {
body: 'body',
title: 'body > table:first-of-type',
chartLayout: 'body > table:nth-of-type(2)',
refreshChart: 'body > table:nth-of-type(2) > tbody > tr > td:first-child > table',
onlineChart: 'body > table:nth-of-type(2) > tbody > tr > td:nth-child(2) > table',
firstBigBar: '.big_bar',
suspectTable: 'body > table:nth-of-type(3)',
});
await page.screenshot({ path: resolve(artifactRoot, `ref-traffic-${viewport.name}.png`), fullPage: true });
await page.goto(new URL('hwe/a_npcList.php', baseUrl).toString(), { waitUntil: 'networkidle' });
const npcList = await measure(page, {
body: 'body',
title: 'body > table:first-of-type',
sortSelect: 'select[name="type"]',
list: 'body > table:nth-of-type(2)',
header: 'body > table:nth-of-type(2) tr:first-child',
footer: 'body > table:nth-of-type(3)',
});
await page.screenshot({ path: resolve(artifactRoot, `ref-npc-list-${viewport.name}.png`), fullPage: true });
await page.goto(new URL('hwe/v_battleCenter.php', baseUrl).toString(), { waitUntil: 'networkidle' });
try {
await page.locator('#container').waitFor({ timeout: 10_000 });
} catch {
throw new Error(
`Reference battle center failed to mount: ${JSON.stringify({
url: page.url(),
text: (await page.locator('body').innerText()).slice(0, 500),
html: (await page.content()).slice(-1_000),
consoleErrors,
failedResources,
})}`
);
}
const battleCenter = await measure(page, {
body: 'body',
container: '#container',
topBar: '#container > :first-child',
selectorRow: '#container > .row:nth-child(2)',
previousButton: '#container > .row:nth-child(2) button:first-child',
firstSelect: '#container > .row:nth-child(2) select:first-of-type',
generalCard: '.header-cell',
firstLogHeader: '.header-cell:nth-of-type(1)',
});
await page.screenshot({
path: resolve(artifactRoot, `ref-battle-center-${viewport.name}.png`),
fullPage: true,
});
output[viewport.name] = { myPage, traffic, npcList, battleCenter };
await context.close();
}
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(output) })}\n`);
} finally {
await browser.close();
}
@@ -0,0 +1,178 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_MESSAGE_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_MESSAGE_USER ?? 'refuser1';
const passwordFile = process.env.REF_MESSAGE_PASSWORD_FILE;
const artifactRoot = process.env.REF_MESSAGE_ARTIFACT_DIR;
if (!passwordFile) {
throw new Error('REF_MESSAGE_PASSWORD_FILE is required.');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
const login = async (context, page) => {
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
const globalSalt = await page.locator('#global_salt').inputValue();
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 },
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
};
const ensureGeneral = async (page) => {
await page.goto(new URL('hwe/index.php', baseUrl).toString(), {
waitUntil: 'networkidle',
timeout: 60_000,
});
if (await page.locator('.MessagePanel').isVisible()) {
return;
}
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), {
waitUntil: 'networkidle',
timeout: 60_000,
});
const create = page.getByRole('button', { name: '장수 생성', exact: true });
await create.waitFor({ state: 'visible', timeout: 30_000 });
page.once('dialog', (dialog) => dialog.accept());
await create.click();
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 60_000 });
};
const measure = async (browser, name, viewport) => {
const context = await browser.newContext({
viewport,
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
timezoneId: 'UTC',
ignoreHTTPSErrors: true,
});
try {
const page = await context.newPage();
await login(context, page);
await ensureGeneral(page);
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.BoardHeader').first().waitFor({ state: 'visible' });
const marker = `computed-dom-${name}-${Date.now()}`;
await page.locator('.MessageInputForm select').selectOption('9999');
await page.locator('.MessageInputForm input').fill(marker);
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await page.getByText(marker, { exact: true }).waitFor({ state: 'visible', timeout: 30_000 });
if (artifactRoot) {
const path = resolve(artifactRoot, `message-ref-${name}.png`);
await mkdir(dirname(path), { recursive: true });
await page.locator('.MessagePanel').screenshot({
path,
animations: 'disabled',
});
}
const result = await page.evaluate(() => {
const rect = (element) => {
const box = element.getBoundingClientRect();
return {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
};
};
const required = (selector) => {
const element = document.querySelector(selector);
if (!element) throw new Error(`Missing reference selector: ${selector}`);
return element;
};
const optionalRect = (selector) => {
const element = document.querySelector(selector);
return element ? rect(element) : null;
};
const style = (selector) => getComputedStyle(required(selector));
const input = required('.MessageInputForm input');
const select = required('.MessageInputForm select');
const submit = required('#msg_submit-col button');
const firstPlate = document.querySelector('.msg_plate');
const firstIcon = document.querySelector('.msg_plate .generalIcon');
const panelStyle = style('.MessagePanel');
const headerStyle = style('.BoardHeader');
const plateStyle = firstPlate ? getComputedStyle(firstPlate) : null;
const iconStyle = firstIcon ? getComputedStyle(firstIcon) : null;
return {
panel: rect(required('.MessagePanel')),
inputForm: rect(required('.MessageInputForm')),
select: rect(select),
input: rect(input),
submit: rect(submit),
publicSection: rect(required('.PublicTalk')),
nationalSection: rect(required('.NationalTalk')),
privateSection: rect(required('.PrivateTalk')),
diplomacySection: rect(required('.DiplomacyTalk')),
firstHeader: rect(required('.BoardHeader')),
firstPlate: optionalRect('.msg_plate'),
firstIcon: optionalRect('.msg_plate .generalIcon'),
computed: {
panelDisplay: panelStyle.display,
panelColumns: panelStyle.gridTemplateColumns,
panelFontSize: panelStyle.fontSize,
headerColor: headerStyle.color,
headerOutlineWidth: headerStyle.outlineWidth,
headerBackgroundImage: headerStyle.backgroundImage,
plateBackgroundColor: plateStyle?.backgroundColor ?? null,
plateFontSize: plateStyle?.fontSize ?? null,
plateMinHeight: plateStyle?.minHeight ?? null,
iconObjectFit: iconStyle?.objectFit ?? null,
},
};
});
const submit = page.locator('#msg_submit-col button');
await submit.hover();
const hover = await submit.evaluate((element) => {
const style = getComputedStyle(element);
return {
cursor: style.cursor,
backgroundColor: style.backgroundColor,
};
});
await submit.focus();
const focus = await submit.evaluate((element) => {
const style = getComputedStyle(element);
return {
outline: style.outline,
boxShadow: style.boxShadow,
};
});
const markerPlate = page.locator('.msg_plate').filter({ hasText: marker });
const deleteButton = markerPlate.locator('.btn-delete-msg');
if (await deleteButton.isVisible()) {
page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click();
}
return { ...result, interaction: { hover, focus } };
} finally {
await context.close();
}
};
const browser = await chromium.launch({ headless: true });
try {
const measurements = {
desktop: await measure(browser, 'desktop', { width: 1000, height: 900 }),
mobile: await measure(browser, 'mobile', { width: 500, height: 900 }),
};
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
} finally {
await browser.close();
}
@@ -0,0 +1,117 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_PARITY_USER ?? 'refadmin';
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-npc-policy');
if (!passwordFile) {
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const login = async (context, page) => {
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
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 },
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
};
const browser = await chromium.launch({ headless: true });
try {
const result = {};
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const page = await context.newPage();
await login(context, page);
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
await page.goto(new URL('hwe/v_NPCControl.php', baseUrl).toString(), { waitUntil: 'networkidle' });
try {
await page.locator('#container').waitFor({ timeout: 10_000 });
} catch {
throw new Error(
`Reference NPC policy failed to mount: ${JSON.stringify({
url: page.url(),
text: (await page.locator('body').innerText()).slice(0, 500),
})}`
);
}
result[viewport.name] = await page.evaluate(() => {
const measure = (selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style: {
display: style.display,
gridTemplateColumns: style.gridTemplateColumns,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderColor: style.borderColor,
padding: style.padding,
margin: style.margin,
cursor: style.cursor,
},
};
};
return {
body: measure('body'),
container: measure('#container'),
topBackBar: measure('body > :first-child'),
sectionBar: measure('.section_bar'),
formList: measure('.form_list'),
firstField: measure('.form_list > .col'),
firstInput: measure('input[type="number"]'),
firstInfoButton: measure('.form_list button'),
controlBar: measure('.control_bar'),
resetButton: measure('.reset_btn'),
submitButton: measure('.submit_btn'),
priorityGrid: measure('.half_section_left'),
priorityColumn: measure('.priority-list'),
priorityItem: measure('.priority-list .list-group-item'),
helpButton: measure('.priority_info button'),
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
});
await page.screenshot({
path: resolve(artifactRoot, `ref-npc-policy-${viewport.name}.png`),
fullPage: true,
});
await context.close();
}
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`);
} finally {
await browser.close();
}

Some files were not shown because too many files have changed in this diff Show More