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';
export class InMemoryBattleSimTransport {
private readonly results = new Map<string, BattleSimResultPayload>();
private readonly results = new Map<string, { requesterUserId: string; payload: BattleSimResultPayload }>();
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const result = processBattleSimJob(payload);
this.results.set(jobId, result);
this.results.set(jobId, { requesterUserId, payload: result });
return { status: 'completed', jobId, payload: result };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.results.get(jobId) ?? null;
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
const result = this.results.get(jobId);
if (!result || result.requesterUserId !== requesterUserId) {
return null;
}
return result.payload;
}
}
+22 -17
View File
@@ -44,16 +44,16 @@ export class RedisBattleSimTransport {
this.resultTtlSeconds = options.resultTtlSeconds;
}
private buildResultKey(jobId: string): string {
return `${this.keys.resultKeyPrefix}${jobId}`;
private buildResultKey(jobId: string, requesterUserId: string): string {
return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
}
private buildNotifyKey(jobId: string): string {
return `${this.keys.notifyKeyPrefix}${jobId}`;
private buildNotifyKey(jobId: string, requesterUserId: string): string {
return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
}
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId));
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId));
if (!raw) {
return null;
}
@@ -64,44 +64,49 @@ export class RedisBattleSimTransport {
}
}
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId);
private async waitForResult(
jobId: string,
requesterUserId: string,
timeoutMs: number
): Promise<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId, requesterUserId);
if (existing) {
return existing;
}
const notifyKey = this.buildNotifyKey(jobId);
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
const timeoutSec = toTimeoutSeconds(timeoutMs);
const signal = await this.client.blPop(notifyKey, timeoutSec);
if (!parseBlPopValue(signal)) {
return null;
}
return this.readResult(jobId);
return this.readResult(jobId, requesterUserId);
}
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const job = {
jobId,
requesterUserId,
requestedAt: new Date().toISOString(),
payload,
};
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
const result = await this.waitForResult(jobId, this.requestTimeoutMs);
const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs);
if (result) {
return { status: 'completed', jobId, payload: result };
}
return { status: 'queued', jobId };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId);
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId, requesterUserId);
}
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId);
const notifyKey = this.buildNotifyKey(jobId);
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId, requesterUserId);
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
await this.client.set(resultKey, JSON.stringify(payload), {
EX: this.resultTtlSeconds,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
export interface BattleSimTransport {
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
}
+1
View File
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
export interface BattleSimJob {
jobId: string;
requesterUserId: string;
requestedAt: string;
payload: BattleSimJobPayload;
}
+42 -24
View File
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
return result.element ?? null;
};
export const runBattleSimWorker = async (): Promise<void> => {
export interface BattleSimWorkerOptions {
signal?: AbortSignal;
}
export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise<void> => {
const config = resolveGameApiConfigFromEnv();
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
resultTtlSeconds: config.battleSimResultTtlSeconds,
});
const handleExit = async () => {
await redis.disconnect();
let stopped = options.signal?.aborted ?? false;
const handleExit = () => {
stopped = true;
};
const handleAbort = () => {
stopped = true;
};
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
options.signal?.addEventListener('abort', handleAbort, { once: true });
while (true) {
const item = await redis.client.blPop(keys.queueKey, 0);
const raw = parseBlPopValue(item);
if (!raw) {
continue;
}
try {
while (!stopped) {
// A finite block lets SIGTERM and test AbortSignal stop the worker without
// leaving a Redis operation or a detached lifecycle process behind.
const item = await redis.client.blPop(keys.queueKey, 1);
const raw = parseBlPopValue(item);
if (!raw) {
continue;
}
let job: BattleSimJob | null = null;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
continue;
}
let job: BattleSimJob | null = null;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
continue;
}
try {
const result = processBattleSimJob(job.payload);
await transport.pushResult(job.jobId, result);
} catch (error) {
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
await transport.pushResult(job.jobId, {
result: false,
reason,
});
try {
const result = processBattleSimJob(job.payload);
await transport.pushResult(job.jobId, job.requesterUserId, result);
} catch (error) {
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
await transport.pushResult(job.jobId, job.requesterUserId, {
result: false,
reason,
});
}
}
} finally {
process.off('SIGINT', handleExit);
process.off('SIGTERM', handleExit);
options.signal?.removeEventListener('abort', handleAbort);
await redis.disconnect();
}
};
+3
View File
@@ -30,6 +30,9 @@ export * from './auction/types.js';
export * from './auction/keys.js';
export * from './auction/scheduler.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';
// 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> => {
const nation = await resolveNationInfo(db, general.nationId);
const picture = general.picture?.trim() || 'default.jpg';
return {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: nation.name,
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 { getDexLevel } from '@sammo-ts/logic';
import { authedProcedure, procedure, router } from '../../trpc.js';
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
import {
@@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => {
return value;
};
const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => {
const userId = auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
}
return userId;
};
const resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
const expLevel = meta.explevel ?? meta.expLevel;
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
@@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
};
export const battleRouter = router({
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
@@ -58,10 +66,10 @@ export const battleRouter = router({
}
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
return ctx.battleSim.simulate(payload);
return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth));
}),
getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId);
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
if (!result) {
return { status: 'queued', jobId: input.jobId };
}
+10 -29
View File
@@ -37,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => {
};
const resolveUserSettings = (meta: Record<string, unknown>) => {
const settings = asRecord(meta.userSettings);
const mysetRaw = settings.myset;
// The legacy general columns are persisted at the top level of General.meta.
// 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;
return {
tnmt: readNumber(settings.tnmt, 1),
defence_train: readNumber(settings.defence_train, 80),
use_treatment: readNumber(settings.use_treatment, 10),
use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1),
tnmt: readNumber(readSetting('tnmt'), 1),
defence_train: readNumber(readSetting('defence_train'), 80),
use_treatment: readNumber(readSetting('use_treatment'), 10),
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
myset,
};
};
@@ -262,29 +266,6 @@ export const generalRouter = router({
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 };
}),
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 { 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 {
appendInheritanceLog,
@@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'success',
'fail',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
@@ -34,8 +41,8 @@ const BUFF_LABELS: Record<InheritBuffType, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
success: '내정 성공률 증가',
fail: '내정 실패율 감소',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패율 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
@@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
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 worldState = await ctx.db.worldState.findFirst();
if (!worldState || typeof worldState !== 'object') {
@@ -199,6 +237,9 @@ export const inheritRouter = router({
special2Code: true,
meta: true,
turnTime: true,
leadership: true,
strength: true,
intel: true,
},
});
@@ -219,7 +260,7 @@ export const inheritRouter = router({
const inheritConst = resolveInheritConstants(worldState);
const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff);
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;
}, {});
@@ -240,11 +281,14 @@ export const inheritRouter = router({
info: trait.info ?? '',
}));
const others = await ctx.db.general.findMany({
where: { id: { not: general.id }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
});
const [others, availableUnique] = await Promise.all([
ctx.db.general.findMany({
where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
}),
loadAvailableUniqueItems(worldState),
]);
return {
items,
@@ -260,10 +304,16 @@ export const inheritRouter = router({
resetTurnTime: resetTurnLevel,
},
availableSpecialWar: warSpecials,
availableUnique,
availableTargetGenerals: others,
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
isUnited,
currentSpecialWar: general.special2Code ?? 'None',
currentStat: {
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
},
};
}),
getLogs: authedProcedure
@@ -285,7 +335,7 @@ export const inheritRouter = router({
},
orderBy: { id: 'desc' },
take: 30,
select: { id: true, year: true, month: true, text: true },
select: { id: true, year: true, month: true, text: true, createdAt: true },
});
return logs;
}),
@@ -318,7 +368,7 @@ export const inheritRouter = router({
}
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) {
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(
ctx.db,
userId,
@@ -460,7 +515,8 @@ export const inheritRouter = router({
}
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);
await patchGeneral(ctx, general.id, {
@@ -473,7 +529,13 @@ export const inheritRouter = router({
});
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 };
}),
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
@@ -624,9 +686,7 @@ export const inheritRouter = router({
const finalBonus =
bonusSum === 0
? buildRandomBonus(
new LiteHashDRBG(
`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`
),
new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
[input.leadership, input.strength, input.intel]
)
: (bonus as [number, number, number]);
@@ -674,9 +734,7 @@ export const inheritRouter = router({
if (seasonValue !== null) {
const userState = await readUserStateMeta(ctx.db, userId);
const resetSeasons = readResetSeasons(userState);
const nextSeasons = resetSeasons.includes(seasonValue)
? resetSeasons
: [...resetSeasons, seasonValue];
const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
await writeUserStateMeta(ctx.db, userId, {
...userState,
last_stat_reset: nextSeasons,
@@ -709,7 +767,10 @@ export const inheritRouter = router({
}
const meta = asRecord(general.meta);
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, {
@@ -803,7 +864,9 @@ export const inheritRouter = router({
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 appendInheritanceLog(
+178 -16
View File
@@ -1,5 +1,7 @@
import { TRPCError } from '@trpc/server';
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 {
@@ -26,6 +28,75 @@ import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js
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({
getRecent: authedProcedure
.input(
@@ -85,11 +156,12 @@ export const messagesRouter = router({
: null,
]);
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: diplomacyMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
let nextSequence = sequence;
@@ -128,10 +200,8 @@ export const messagesRouter = router({
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
canRespondDiplomacy:
general.officerLevel > 4 &&
nation !== null &&
resolveNationPermission(general, nation.meta, false) >= 4,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
@@ -178,6 +248,7 @@ export const messagesRouter = router({
];
return {
nation: nationList.map((nation) => ({
nationId: nation.id,
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
name: nation.name,
color: nation.color,
@@ -234,14 +305,24 @@ export const messagesRouter = router({
if (message.payload.src.generalId !== general.id) {
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: '삭제할 수 없는 메시지입니다.' });
}
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
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);
return { ok: true, deletedIds: ids };
}),
@@ -291,6 +372,14 @@ export const messagesRouter = router({
const general = await getOwnedGeneral(ctx, input.generalId);
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 = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
@@ -312,7 +401,8 @@ export const messagesRouter = router({
toSeq: input.to,
limit: 15,
});
messageBuckets[input.type] = messages;
messageBuckets[input.type] =
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
return {
result: true,
@@ -320,6 +410,7 @@ export const messagesRouter = router({
sequence: 0,
nationId,
generalName: general.name,
permission,
...messageBuckets,
};
}),
@@ -333,6 +424,12 @@ export const messagesRouter = router({
)
.mutation(async ({ ctx, input }) => {
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 now = new Date();
@@ -340,28 +437,93 @@ export const messagesRouter = router({
let msgType: MessageType;
let dest = src;
let receiverMailbox = input.mailbox;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
if (destNationId <= 0) {
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid nation mailbox.',
code: 'FORBIDDEN',
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);
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);
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
} 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({
where: { id: input.mailbox },
});
if (!destGeneral) {
throw new TRPCError({
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);
@@ -394,7 +556,7 @@ export const messagesRouter = router({
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
type: 'messageCreated',
at: now.toISOString(),
mailbox: input.mailbox,
mailbox: receiverMailbox,
msgType,
messageId: result.receiverId,
senderId: general.id,
@@ -2,7 +2,21 @@ import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.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 }) => {
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 troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
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 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 {
nation: {
@@ -79,6 +124,7 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
capitalCityId: nation.capitalCityId ?? 0,
},
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 { getCityOverview } from './endpoints/getCityOverview.js';
import { getGeneralList } from './endpoints/getGeneralList.js';
import { getSecretGeneralList } from './endpoints/getSecretGeneralList.js';
import { getGeneralLog } from './endpoints/getGeneralLog.js';
import { getNationInfo } from './endpoints/getNationInfo.js';
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
@@ -21,6 +22,7 @@ import { setSecretLimit } from './endpoints/setSecretLimit.js';
export const nationRouter = router({
getNationInfo,
getGeneralList,
getSecretGeneralList,
getCityOverview,
getPersonnelInfo,
getStratFinan,
+255 -265
View File
@@ -1,5 +1,3 @@
import path from 'node:path';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const;
type NumericPolicyKey = (typeof INTEGER_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 => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
@@ -353,8 +349,8 @@ const buildZeroPolicy = async (
}
): Promise<NationPolicy> => {
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT });
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId);
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
const techCost = getTechCost(nationTech);
const next = clonePolicy(policy);
@@ -364,7 +360,7 @@ const buildZeroPolicy = async (
if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) {
const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0;
const baseRice = statNpcMax;
const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0;
if (next.reqNPCWarGold === 0) {
next.reqNPCWarGold = roundTo(baseGold * 4, -2);
}
@@ -375,7 +371,7 @@ const buildZeroPolicy = async (
if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) {
const baseGold = crewType ? crewType.cost * techCost * statMax : 0;
const baseRice = statMax;
const baseRice = crewType ? crewType.rice * techCost * statMax : 0;
if (next.reqHumanWarUrgentGold === 0) {
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 orderRequired: Array<[string, string]> = [['출병', '일반내정']];
const mustHave = new Set(['출병', '일반내정']);
@@ -461,6 +455,7 @@ export const npcRouter = router({
id: true,
name: true,
level: true,
tech: true,
meta: true,
},
}),
@@ -508,9 +503,9 @@ export const npcRouter = router({
const stat = resolveScenarioStat(config);
const env = resolveCommandEnv(config);
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,
statNpcMax: stat.npcMax,
nationTech,
@@ -542,277 +537,272 @@ export const npcRouter = router({
permissionLevel,
};
}),
setNationPolicy: authedProcedure
.input(z.record(z.string(), z.unknown()))
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).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 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 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 keys = Object.keys(input);
for (const key of keys) {
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
const keys = Object.keys(input);
for (const key of keys) {
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}는 올바른 정책값이 아닙니다.` });
}
}
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;
if (!troopSet.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${rawValue}는 국가의 부대가 아닙니다.`,
});
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
if (assigned.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
});
}
nextValues[key] = Math.max(0, value);
assigned.add(rawValue);
list.push(rawValue);
}
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] = Math.max(0, value);
if (key === 'SupportForce') {
nextValues.SupportForce = list;
} else {
nextValues.DevelopForce = list;
}
}
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;
const nextPolicyRoot = {
...policyRoot,
values: nextValues,
valueSetter: general.name,
valueSetTime: new Date().toISOString(),
};
await updateNationMeta(
ctx,
nation.id,
{
npc_nation_policy: nextPolicyRoot,
},
nationMeta
);
return { ok: true };
}),
setNationPriority: 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: '권한이 부족합니다.' });
}
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) {
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}는 올바른 정책값이 아닙니다.` });
}
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 nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextPolicyRoot = {
...policyRoot,
priority: input,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
const nextPolicyRoot = {
...policyRoot,
values: nextValues,
valueSetter: general.name,
valueSetTime: new Date().toISOString(),
};
await updateNationMeta(
ctx,
nation.id,
{
npc_nation_policy: nextPolicyRoot,
},
nationMeta
);
await updateNationMeta(
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.' });
}
return { ok: true };
}),
setNationPriority: 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 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 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 validationError = validateGeneralPriority(input);
if (validationError) {
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
}
const unique = ensureUniquePriority(input);
for (const item of unique) {
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
}
}
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_general_policy);
const nextPolicyRoot = {
...policyRoot,
priority: input,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextPolicyRoot = {
...policyRoot,
priority: unique,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
await updateNationMeta(
ctx,
nation.id,
{
npc_general_policy: nextPolicyRoot,
},
nationMeta
);
await updateNationMeta(
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 };
}),
return { ok: true };
}),
});
+117
View File
@@ -42,6 +42,14 @@ type NationCountRow = {
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 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;
};
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 => {
if (left === right) {
return 0;
@@ -222,6 +250,95 @@ export const publicRouter = router({
getNationList: procedure.query(async ({ 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 }) => {
const [generals, nations] = await Promise.all([
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 { 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_FG_COLOR = '#ffffff';
@@ -23,31 +26,31 @@ const readMetaNumber = (value: unknown): number => {
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();
let cachedUniqueItems: Promise<
Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }>
> | null = null;
let cachedUniqueItems: Promise<ItemModule[]> | null = null;
const loadUniqueItems = () => {
if (!cachedUniqueItems) {
cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) =>
modules
.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,
}))
modules.filter((module) => module.unique && !module.buyable)
);
}
return cachedUniqueItems;
};
export const rankingRouter = router({
getBestGeneral: procedure
getBestGeneral: authedProcedure
.input(
z
.object({
@@ -57,7 +60,7 @@ export const rankingRouter = router({
)
.query(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst({
select: { meta: true },
select: { meta: true, config: true },
});
const meta = asRecord(worldState?.meta);
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
@@ -76,6 +79,7 @@ export const rankingRouter = router({
userId: true,
picture: true,
imageServer: true,
meta: true,
experience: true,
dedication: true,
horseCode: true,
@@ -185,7 +189,7 @@ export const rankingRouter = router({
let display = {
id: general.id,
name: general.name,
ownerName: general.userId ?? null,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
@@ -217,46 +221,91 @@ export const rankingRouter = router({
});
const uniqueItems = await loadUniqueItems();
const itemEntries = uniqueItems.map((item) => {
const owners = generals.filter((general) => {
if (item.slot === 'horse') {
return general.horseCode === item.key;
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
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') {
return general.weaponCode === item.key;
const owners = generals
.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') {
return general.bookCode === item.key;
}
return general.itemCode === item.key;
const count = Math.max(0, Math.floor(rawCount));
return Array.from({ length: count }, (_, index) => ({
itemKey,
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,
},
}));
});
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 { title: slotTitles[slot], slot, entries };
});
return {
@@ -339,6 +388,10 @@ export const rankingRouter = router({
return {
generalId: row.generalNo,
name: String(aux.name ?? ''),
ownerName:
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
? aux.ownerDisplayName
: null,
nationName: String(aux.nationName ?? ''),
bgColor: String(aux.bgColor ?? DEFAULT_BG_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 { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
const isWorldAdmin = (roles: readonly string[]): boolean =>
@@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => {
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) => ({
scenarioCode: row.scenarioCode,
currentYear: row.currentYear,
@@ -122,6 +146,8 @@ export const worldRouter = router({
if (me.officerLevel > 0 && me.nationId > 0) {
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
nationGenerals.forEach((general) => selectable.add(general.cityId));
}
if ((nation?.level ?? 0) > 0) {
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
}
if (admin) cities.forEach((city) => selectable.add(city.id));
@@ -150,6 +176,8 @@ export const worldRouter = router({
turnMap.set(turn.generalId, list);
}
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({
where: { officerLevel: { in: [2, 3, 4] } },
select: { name: true, officerLevel: true, meta: true },
@@ -175,14 +203,59 @@ export const worldRouter = router({
intelligence: general.intel,
injury: general.injury,
officerLevel: general.officerLevel,
leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0),
defenceTrain: ours ? defenceTrain(general.meta) : null,
crewTypeId: ours ? general.crewTypeId : null,
crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null,
crew: ours || full ? general.crew : null,
train: ours ? general.train : null,
atmos: ours ? general.atmos : null,
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 {
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
options: [...selectable]
@@ -194,6 +267,7 @@ export const worldRouter = router({
id: selected.id,
name: selected.name,
nationId: selected.nationId,
nationColor: selectedNation?.color ?? '#000000',
level: selected.level,
region: selected.region,
population: redact(selected.population),
@@ -217,8 +291,11 @@ export const worldRouter = router({
},
},
generals: mappedGenerals,
forceSummary,
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 }) => {
+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 {
createGamePostgresConnector,
createRedisConnector,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
type GamePrismaClient,
} from '@sammo-ts/infra';
import { resolveGameApiConfigFromEnv } from '../config.js';
import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js';
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js';
@@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: {
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 rewardPayload = buildTournamentRewardPayload(matches);
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentReward',
requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`,
tournamentType: state.type,
winnerId: rewardPayload.winnerId,
runnerUpId: rewardPayload.runnerUpId,
@@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: {
top8: rewardPayload.top8,
top4: rewardPayload.top4,
});
requireSuccessfulResult(result, 'tournamentReward');
settledState = {
...(settledState ?? state),
...settledState,
rewardSettled: true,
};
changed = true;
await store.setState(settledState);
}
if (state.bettingId && !state.bettingSettled) {
if (settledState.bettingId && !settledState.bettingSettled) {
const bettingEntries = await store.getBettingEntries();
if (bettingEntries.length > 0) {
const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries);
const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries);
if (payoutInfo.payouts.length > 0) {
if (payoutInfo.refundAll) {
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentRefund',
bettingId: state.bettingId,
requestId: `tournament:${settledState.bettingId}:betting-refund`,
bettingId: settledState.bettingId,
refunds: payoutInfo.payouts,
reason: 'no_winner',
});
requireSuccessfulResult(result, 'tournamentRefund');
} else {
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentBettingPayout',
bettingId: state.bettingId,
requestId: `tournament:${settledState.bettingId}:betting-payout`,
bettingId: settledState.bettingId,
payouts: payoutInfo.payouts,
reason: 'winner_payout',
});
requireSuccessfulResult(result, 'tournamentBettingPayout');
}
}
}
settledState = {
...(settledState ?? state),
...settledState,
bettingSettled: true,
};
}
if (settledState) {
changed = true;
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> => {
@@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise<void> => {
await redis.connect();
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
const daemonTransport = new RedisTurnDaemonTransport(redis.client, {
keys: buildTurnDaemonStreamKeys(config.profileName),
requestTimeoutMs: config.daemonRequestTimeoutMs,
});
const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
const handleExit = async () => {
await redis.disconnect();
@@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise<void> => {
while (true) {
const state = await store.getState();
if (!state || !state.auto) {
if (!state || (!state.auto && !needsSettlement(state))) {
await sleepMs(config.tournamentPollMs);
continue;
}
const nextAt = new Date(state.nextAt).getTime();
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));
continue;
}
try {
await store.withMutationLock(async () => {
const lockedState = await store.getState();
if (!lockedState || !lockedState.auto) {
return;
}
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,
});
await processTournamentTick({
store,
prisma: postgres.prisma,
daemonTransport,
});
} catch (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 = {
...state,
auto: false,
...currentState,
lastError: message,
lastErrorAt: now,
};
+3 -2
View File
@@ -520,8 +520,9 @@ export const buildBettingPayouts = (
const winners = entries.filter((entry) => entry.targetId === winnerId);
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
if (winnersTotal <= 0) {
const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount }));
return { payouts: refunds, total, refundAll: true };
// Legacy Betting::_calcRewardExclusive() builds a refund candidate list
// but returns no rewards when nobody selected the winner.
return { payouts: [], total, refundAll: false };
}
const ratio = total / winnersTotal;
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 requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
});
}
return next({
ctx: {
...ctx,
auth: ctx.auth,
},
});
});
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
@@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
if (!ctx.auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
});
}
return next({
ctx: {
...ctx,
auth: ctx.auth,
},
});
});
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
+2
View File
@@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
};
};
+230 -21
View File
@@ -19,19 +19,30 @@ const profile: GameProfile = {
class QueuedBattleSimTransport implements BattleSimTransport {
public simulateCalls = 0;
public lastPayload: BattleSimJobPayload | null = null;
public lastRequesterUserId: string | null = null;
private readonly owners = new Map<string, string>();
private readonly results = new Map<string, BattleSimResultPayload>();
async simulate(payload: BattleSimJobPayload) {
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
this.simulateCalls += 1;
this.lastPayload = payload;
return { status: 'queued', jobId: 'job-1' } as const;
this.lastRequesterUserId = requesterUserId;
const jobId = `job-${this.simulateCalls}`;
this.owners.set(jobId, requesterUserId);
return { status: 'queued', jobId } as const;
}
async getSimulationResult(jobId: string) {
async getSimulationResult(jobId: string, requesterUserId: string) {
if (this.owners.get(jobId) !== requesterUserId) {
return null;
}
return this.results.get(jobId) ?? null;
}
pushResult(jobId: string, payload: BattleSimResultPayload) {
pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) {
if (this.owners.get(jobId) !== requesterUserId) {
throw new Error('requester mismatch');
}
this.results.set(jobId, payload);
}
}
@@ -194,8 +205,13 @@ const buildBattleRequest = () => ({
},
});
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
const db = {
const buildContext = (options: {
state: WorldStateRow;
battleSim: BattleSimTransport;
userId?: string | null;
db?: Partial<DatabaseClient>;
}): GameApiContext => {
const db = options.db ?? {
worldState: {
findFirst: async () => options.state,
},
@@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans
},
profile.name
);
const auth: GameSessionTokenPayload = {
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
sessionId: 'session-1',
user: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
const auth: GameSessionTokenPayload | null =
options.userId === null
? null
: {
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
sessionId: 'session-1',
user: {
id: options.userId ?? 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
@@ -255,14 +274,204 @@ describe('battle router orchestration', () => {
const response = await caller.battle.simulate(buildBattleRequest());
expect(response.status).toBe('queued');
expect(battleSim.simulateCalls).toBe(1);
expect(battleSim.lastRequesterUserId).toBe('user-1');
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
expect(queued.status).toBe('queued');
battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 });
battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 });
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
expect(completed.status).toBe('completed');
expect(completed.payload?.result).toBe(true);
});
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
let transactionCalls = 0;
const db = {
worldState: { findFirst: async () => state },
$transaction: async () => {
transactionCalls += 1;
throw new Error('simulation must not create an input event transaction');
},
} as unknown as DatabaseClient;
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const noGeneralUser = appRouter.createCaller(
buildContext({ state, battleSim, userId: 'user-without-general', db })
);
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
status: 'queued',
});
expect(transactionCalls).toBe(0);
expect(battleSim.lastRequesterUserId).toBe('user-without-general');
});
it('does not expose queued results across authenticated users', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' }));
const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' }));
const response = await owner.battle.simulate(buildBattleRequest());
battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 });
await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({
status: 'completed',
payload: { avgWar: 7 },
});
await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({
status: 'queued',
jobId: response.jobId,
});
});
});
describe('battle simulator general import permissions', () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const buildGeneral = (overrides: Record<string, unknown>) => ({
id: 1,
userId: 'same-nation-user',
name: '관전자',
npcState: 0,
nationId: 1,
leadership: 70,
strength: 71,
intel: 72,
officerLevel: 1,
injury: 0,
rice: 9000,
crew: 5000,
crewTypeId: 100,
atmos: 100,
train: 100,
experience: 400,
horseCode: null,
weaponCode: null,
bookCode: null,
itemCode: null,
personalCode: null,
special2Code: null,
meta: {},
...overrides,
});
const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 });
const ally = buildGeneral({
id: 2,
userId: 'ally-user',
name: '아군 장수',
nationId: 1,
officerLevel: 4,
rice: 4321,
crew: 3210,
train: 97,
atmos: 96,
horseCode: 'che_적토마',
weaponCode: 'che_의천검',
bookCode: 'che_손자병법',
itemCode: 'che_옥새',
meta: {
dex1: 10000,
rank_warnum: 33,
rank_killnum: 22,
rank_killcrew: 1111,
},
});
const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 });
const generals = [actor, ally, foreignActor];
const db = {
worldState: { findFirst: async () => state },
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
generals.find((general) => general.userId === where.userId) ?? null,
findUnique: async ({ where }: { where: { id: number } }) =>
generals.find((general) => general.id === where.id) ?? null,
},
} as unknown as DatabaseClient;
it('returns full ally details to the same nation but redacts them for another nation', async () => {
const battleSim = new QueuedBattleSimTransport();
const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db }));
const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db }));
const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id });
expect(visible.general).toMatchObject({
name: '아군 장수',
officer_level: 4,
horse: 'che_적토마',
crew: 3210,
rice: 4321,
train: 97,
atmos: 96,
warnum: 33,
killnum: 22,
killcrew: 1111,
});
const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id });
expect(redacted.general).toMatchObject({
name: '아군 장수',
officer_level: 1,
horse: null,
weapon: null,
book: null,
item: null,
crew: 0,
rice: 10000,
dex1: 0,
warnum: 0,
killnum: 0,
killcrew: 0,
});
});
it('requires a game general only for server-side general import', async () => {
const caller = appRouter.createCaller(
buildContext({
state,
battleSim: new QueuedBattleSimTransport(),
userId: 'user-without-general',
db,
})
);
await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js';
class FakeRedisClient {
readonly values = new Map<string, string>();
readonly lists = new Map<string, string[]>();
async rPush(key: string, value: string): Promise<number> {
const list = this.lists.get(key) ?? [];
list.push(value);
this.lists.set(key, list);
return list.length;
}
async blPop(): Promise<null> {
return null;
}
async set(key: string, value: string): Promise<'OK'> {
this.values.set(key, value);
return 'OK';
}
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async expire(): Promise<number> {
return 1;
}
}
describe('RedisBattleSimTransport requester isolation', () => {
it('records the requester on queued jobs and scopes completed results to that user', async () => {
const client = new FakeRedisClient();
const keys = buildBattleSimQueueKeys('che:test');
const transport = new RedisBattleSimTransport(client, {
keys,
requestTimeoutMs: 1,
resultTtlSeconds: 60,
});
const response = await transport.simulate({} as BattleSimJobPayload, 'user/one');
expect(response.status).toBe('queued');
const queuedRaw = client.lists.get(keys.queueKey)?.[0];
expect(queuedRaw).toBeTruthy();
expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({
jobId: response.jobId,
requesterUserId: 'user/one',
});
await transport.pushResult(response.jobId, 'user/one', {
result: true,
reason: 'success',
avgWar: 3,
});
await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({
result: true,
avgWar: 3,
});
await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull();
expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true);
});
});
@@ -0,0 +1,94 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildBattleSimEnvironment } from '../src/battleSim/environment.js';
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
import type { BattleSimRequestPayload } from '../src/battleSim/types.js';
import { runBattleSimWorker } from '../src/battleSim/worker.js';
import type { WorldStateRow } from '../src/context.js';
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
afterEach(() => {
vi.unstubAllEnvs();
});
liveDescribe('battle simulator worker with live Redis', () => {
it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => {
const scenario = `battle-sim-e2e-${randomUUID()}`;
const profileName = `che:${scenario}`;
const requesterUserId = 'worker-e2e-user';
vi.stubEnv('PROFILE', 'che');
vi.stubEnv('SCENARIO', scenario);
vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only');
const fixturePath = path.resolve(
process.cwd(),
'../../tools/integration-tests/fixtures/battle/basic-infantry.json'
);
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & {
startYear: number;
};
const { startYear, ...request } = fixture;
const worldState: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: request.year,
currentMonth: request.month,
tickSeconds: 600,
config: {},
meta: { scenarioMeta: { startYear } },
updatedAt: new Date(),
};
const environment = await buildBattleSimEnvironment(worldState, 'che');
const payload = {
...request,
unitSet: environment.unitSet,
config: environment.config,
time: { year: request.year, month: request.month, startYear },
};
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
await clientConnector.connect();
const keys = buildBattleSimQueueKeys(profileName);
const transport = new RedisBattleSimTransport(clientConnector.client, {
keys,
requestTimeoutMs: 15_000,
resultTtlSeconds: 60,
});
const abortController = new AbortController();
const worker = runBattleSimWorker({ signal: abortController.signal });
let jobId: string | null = null;
try {
const result = await transport.simulate(payload, requesterUserId);
jobId = result.jobId;
expect(result.status).toBe('completed');
if (result.status === 'completed') {
expect(result.payload).toMatchObject({
result: true,
reason: 'success',
avgWar: 1,
});
expect(result.payload.phase).toBeGreaterThan(0);
}
} finally {
abortController.abort();
await worker;
if (jobId) {
const encodedRequester = encodeURIComponent(requesterUserId);
await clientConnector.client.del([
keys.queueKey,
`${keys.resultKeyPrefix}${encodedRequester}:${jobId}`,
`${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`,
]);
}
await clientConnector.disconnect();
}
});
});
+55 -13
View File
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
updatedAt: now,
...overrides,
});
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: 'session',
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
user: { id: userId, username: 'tester', displayName: 'Tester', roles },
sanctions: {},
});
const city = (id: number, nationId: number) => ({
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
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 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 db = {
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> }) => {
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 === 3) return [foreign];
if (args.where?.officerLevel) return [];
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
id: 1,
name: '아국',
color: '#008000',
level: 1,
level: options.nationLevel ?? 1,
capitalCityId: 1,
meta: options.nationMeta ?? {},
})),
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: auth(options.roles),
auth: auth(options.roles, options.userId),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
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 () => {
const result = await appRouter
.createCaller(context({ me: general({ cityId: 80 }) }))
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
expect(result.visibility.full).toBe(true);
expect(result.city.population).toBe(1000);
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 () => {
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
expect(result.options).toHaveLength(4);
expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
});
it.each(['admin', 'superuser', 'admin.superuser'])(
'allows the %s role to inspect all city and general fields',
async (role) => {
const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
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: {},
};
const buildContext = (overrides: Record<string, unknown> = {}) => {
const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async () => 1);
const updateMany = vi.fn(async () => ({ count: 1 }));
const db = {
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
$executeRaw: executeRaw,
...overrides,
};
const redis = {
set: vi.fn(async () => 'OK'),
publish: vi.fn(async () => 1),
};
const context = {
db,
auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {},
redis,
turnDaemon: {},
battleSim: {},
uploadDir: 'uploads',
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
accessTokenStore: {},
flushStore: {},
gameTokenSecret: 'test-secret',
...contextOverrides,
} 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', () => {
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
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 () => {
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 () => {
const queryRaw = vi.fn(async () => [
{
@@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071;
const concurrentBettingId = 990_072;
const generalId = 9_971;
const otherGeneralId = 9_972;
const nationId = 990_071;
const otherNationId = 990_072;
const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user';
const auth: GameSessionTokenPayload = {
version: 1,
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const otherAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-other-session',
user: {
...auth.user,
id: otherUserId,
username: 'other-bettor',
displayName: 'Other Bettor',
},
};
const noGeneralAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-no-general-session',
user: {
...auth.user,
id: noGeneralUserId,
username: 'no-general',
displayName: 'No General',
},
};
integration('nation betting router', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
const buildContext = (requestId: string): GameApiContext => {
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
auth,
auth: actorAuth,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId } });
await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.nation.create({
data: {
id: nationId,
name: '베팅국',
color: '#123456',
level: 2,
},
await db.nation.createMany({
data: [
{
id: nationId,
name: '베팅국',
color: '#123456',
level: 2,
},
{
id: otherNationId,
name: '다른베팅국',
color: '#654321',
level: 6,
},
],
});
await db.general.create({
data: {
id: generalId,
userId,
name: '베팅장수',
nationId,
cityId: 1,
npcState: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
await db.general.createMany({
data: [
{
id: generalId,
userId,
name: '베팅장수',
nationId,
cityId: 1,
npcState: 0,
officerLevel: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
{
id: otherGeneralId,
userId: otherUserId,
name: '다른국가수뇌',
nationId: otherNationId,
cityId: 1,
npcState: 0,
officerLevel: 12,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
],
});
const world = await db.worldState.create({
data: {
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
},
});
await db.inheritancePoint.create({
data: { userId, key: 'previous', value: 1_000 },
await db.inheritancePoint.createMany({
data: [
{ userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 },
],
});
});
afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId } });
await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.();
});
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
}),
]);
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
.toMatchObject({ _sum: { amount: 600 } });
expect(
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
).toMatchObject({ _sum: { amount: 600 } });
expect(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId, key: 'previous' } },
})
).toMatchObject({ value: 250 });
});
it('requires authentication and an owned player general for every betting operation', async () => {
await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
req: 'bettingNation',
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter
.createCaller(buildContext('nation-betting-anonymous-detail', null))
.betting.getDetail({ bettingId })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({
bettingId,
bettingType: [0],
amount: 10,
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({
req: 'bettingNation',
})
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
await expect(
appRouter
.createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth))
.betting.getDetail({ bettingId })
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
await expect(
appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({
bettingId,
bettingType: [0],
amount: 10,
})
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
});
it('allows generals across nation and office levels while isolating each session user bet', async () => {
await expect(
appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({
req: 'bettingNation',
})
).resolves.toMatchObject({
result: true,
bettingList: {
[bettingId]: { name: '천통국 예상' },
},
});
await expect(
appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({
bettingId,
bettingType: [0],
amount: 100,
})
).resolves.toEqual({ result: true });
const [firstUserDetail, otherUserDetail] = await Promise.all([
appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }),
appRouter
.createCaller(buildContext('nation-betting-other-user-detail', otherAuth))
.betting.getDetail({ bettingId }),
]);
expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]);
expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]);
expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]);
expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]);
expect(
await db.nationBet.findUniqueOrThrow({
where: {
bettingId_userId_selectionKey: {
bettingId,
userId: otherUserId,
selectionKey: '[0]',
},
},
})
).toMatchObject({
generalId: otherGeneralId,
userId: otherUserId,
amount: 100,
});
expect(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: otherUserId, key: 'previous' } },
})
).toMatchObject({ value: 400 });
expect(
await db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } },
})
).toMatchObject({ nationId: otherNationId, value: 100 });
});
});
@@ -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,
} from '../src/tournament/types.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';
class MemoryRedis {
@@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
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 () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
@@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => {
const sent: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async (command) => {
sendCommand: async () => 'unused',
requestCommand: async (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,
};
@@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => {
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
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 () => {
@@ -35,6 +35,8 @@ const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10;
const DEFAULT_MAX_TECH_LEVEL = 12;
const DEFAULT_BASE_GOLD = 0;
const DEFAULT_BASE_RICE = 2000;
const DEFAULT_GENERAL_MINIMUM_GOLD = 0;
const DEFAULT_GENERAL_MINIMUM_RICE = 500;
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
const normalizeCode = (value: string | null | undefined): string | null => {
@@ -132,6 +134,8 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE),
maxResourceActionAmount: resolveNumber(
constValues,
['maxResourceActionAmount'],
@@ -809,9 +809,35 @@ async function handleVacation(
if (!general) {
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 };
}
const normalizeDefenceTrain = (value: number): number => {
if (value <= 40) {
return 40;
}
if (value <= 90) {
return Math.round(value / 10) * 10;
}
return 999;
};
async function handleSetMySetting(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
@@ -826,11 +852,48 @@ async function handleSetMySetting(
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, {
meta: {
...general.meta,
...command.settings,
},
meta: nextMeta,
train: nextTrain,
atmos: nextAtmos,
});
return { type: 'setMySetting', ok: true, generalId: command.generalId };
}
@@ -844,10 +907,8 @@ async function handleDropItem(
if (!general) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
}
const slot = (['horse', 'weapon', 'book', 'item'] as const).find(
(candidate) => general.role.items[candidate] === command.itemType
);
if (!slot) {
const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType);
if (!slot || !general.role.items[slot]) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
}
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 { 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 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) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const city = {
@@ -46,19 +68,68 @@ const layout = {
regionMap: { 1: '하북' },
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') => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_info');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
);
await page.route('**/image/**', async (route) => {
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) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
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')
return response({
nation: {
@@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
id: 1,
name: '업',
nationId: 1,
nationColor: '#008000',
level: 8,
region: 1,
population: mode === 'wanderer' ? null : 150000,
@@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
intelligence: 50,
injury: 0,
officerLevel: 1,
leadershipBonus: 0,
defenceTrain: 80,
crewTypeId: 1,
crewTypeName: '보병',
crew: 500,
train: 90,
atmos: 90,
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',
});
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 }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
for (const [path, selector] of [
['nation/info', '.legacy-info-page'],
['nation/cities', '.nation-cities-page'],
['global-info', '.global-page'],
['current-city', '.city-page'],
for (const [path, selector, fontSize, fontFamily, borderCollapse] of [
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
] as const) {
await go(page, path);
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.x).toBe(100);
expect(box.fontSize).toBe('14px');
expect(box.fontFamily).toContain('Pretendard');
expect(box.fontSize).toBe(fontSize);
expect(box.fontFamily).toContain(fontFamily);
expect(
await page
.locator('table')
.first()
.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 }) => {
await install(page, 'admin');
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'current-city');
await expect(page.locator('.generals')).toContainText('장수');
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({
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,
workers: 1,
timeout: 30_000,
+2
View File
@@ -9,7 +9,9 @@
"preview": "vite preview",
"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:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"",
@@ -7,6 +7,7 @@ interface Props {
options: BattleSimOptions;
mode: 'attacker' | 'defender';
title: string;
canImportServer: boolean;
}
const props = defineProps<Props>();
@@ -59,7 +60,19 @@ const officerLevelOptions = [
<div class="general-subtitle">No {{ general.no }}</div>
</div>
<div class="general-actions">
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
<button
class="action"
type="button"
:disabled="!canImportServer"
:title="
canImportServer
? '게임 서버의 장수 정보를 불러옵니다.'
: '게임 장수를 보유해야 사용할 수 있습니다.'
"
@click="emit('import')"
>
서버에서 가져오기
</button>
<button class="action" type="button" @click="emit('save')">저장</button>
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
<button class="action" type="button" @click="triggerLoad">불러오기</button>
@@ -187,21 +200,11 @@ const officerLevelOptions = [
<div class="form-row">
<label class="field">
<span>훈련</span>
<input
v-model.number="general.train"
type="number"
min="40"
:max="options.config.maxTrainByWar"
/>
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
</label>
<label class="field">
<span>사기</span>
<input
v-model.number="general.atmos"
type="number"
min="40"
:max="options.config.maxAtmosByWar"
/>
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
</label>
<label class="field">
<span>전특</span>
@@ -308,30 +311,15 @@ const officerLevelOptions = [
<div class="form-row buff-row">
<label class="field">
<span>상대 회피</span>
<input
v-model.number="general.inheritBuff.warAvoidRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 필살</span>
<input
v-model.number="general.inheritBuff.warCriticalRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 계략</span>
<input
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
</label>
</div>
</div>
@@ -391,6 +379,11 @@ const officerLevelOptions = [
color: #f0b6b6;
}
.action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.form-block {
display: flex;
flex-direction: column;
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
</script>
<template>
<div
<RouterLink
class="map-city"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:class="[
`state-${props.city.stateClass}`,
{ 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')"
@click.stop="emit('select', props.city.id)"
>
<div
class="city-dot"
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
<span v-if="props.city.isCapital" class="capital" />
</div>
<div
v-if="props.city.state > 0"
class="city-state"
: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>
</RouterLink>
</template>
<style scoped>
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.8);
cursor: pointer;
text-decoration: none;
}
.city-dot {
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
</script>
<template>
<div
<RouterLink
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 }]"
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" />
</div>
</div>
</RouterLink>
</template>
<style scoped>
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.9);
cursor: pointer;
text-decoration: none;
}
.city-bg {
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import { computed, reactive } from 'vue';
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 {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
@@ -16,6 +28,22 @@ interface MessageBucket {
public: MessageEntry[];
national: 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<{
@@ -23,7 +51,10 @@ const props = defineProps<{
loading: boolean;
targetMailbox: number;
draftText: string;
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
mailboxGroups: MailboxGroup[];
generalId: number;
generalName: string;
nationId: number;
canRespondDiplomacy: boolean;
}>();
@@ -34,213 +65,390 @@ const emit = defineEmits<{
(event: 'refresh'): void;
(event: 'load-older', type: MessageType): 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 }> = [
{ key: 'public', label: '전체' },
{ key: 'national', label: '국가' },
{ key: 'private', label: '개인' },
{ key: 'diplomacy', label: '외교' },
const sections: Array<{ type: MessageType; label: string; className: string }> = [
{ type: 'public', label: '전체 메시지', className: 'PublicTalk' },
{ type: 'national', label: '국가 메시지', className: 'NationalTalk' },
{ type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
{ type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
];
const activeTab = ref<MessageType>('public');
const activeMessages = computed(() => {
if (!props.messages) {
return [] as MessageEntry[];
}
return props.messages[activeTab.value] ?? [];
const visibleLimits = reactive<Record<MessageType, number>>({
public: Number.POSITIVE_INFINITY,
national: Number.POSITIVE_INFINITY,
private: Number.POSITIVE_INFINITY,
diplomacy: Number.POSITIVE_INFINITY,
});
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 parsed = Number(value);
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
};
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
message.msgType === 'diplomacy' &&
(message.option?.action === 'noAggression' ||
message.option?.action === 'cancelNA' ||
message.option?.action === 'stopWar');
const respond = (messageId: number, response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
const submit = () => {
if (!props.draftText.trim()) {
emit('refresh');
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);
};
</script>
<template>
<div class="message-panel">
<div class="message-input">
<select
class="message-select"
:value="targetMailbox"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
<option
v-for="option in mailboxOptions"
:key="option.label"
:value="option.value"
:disabled="option.disabled"
<div class="MessagePanel">
<div class="MessageInputForm">
<div id="mailbox_list-col">
<select
id="mailbox_list"
class="message-select"
:value="targetMailbox"
aria-label="메시지 수신 대상"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
{{ option.label }}
</option>
</select>
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
placeholder="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="emit('send')"
/>
<button class="message-send" @click="emit('send')">전송</button>
</div>
<div class="message-tabs">
<button
v-for="tab in messageTabs"
:key="tab.key"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
<button class="refresh" @click="emit('refresh')">갱신</button>
</div>
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
<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>
<optgroup
v-for="group in mailboxGroups"
:key="group.label"
:label="group.label"
:style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
>
<option
v-for="option in group.options"
:key="`${group.label}-${option.value}`"
:value="option.value"
:disabled="option.disabled"
:style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
>
{{ option.label }}
</option>
</optgroup>
</select>
</div>
<div id="msg_input-col">
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
aria-label="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="submit"
/>
</div>
<div id="msg_submit-col">
<button class="message-send" type="button" @click="submit">서신전달&amp;갱신</button>
</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>
</template>
<style scoped>
.message-panel {
display: flex;
flex-direction: column;
gap: 12px;
.MessagePanel {
color: #fff;
font-size: 14px;
}
.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;
grid-template-columns: minmax(90px, 120px) 1fr auto;
gap: 6px;
}
.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 {
background: rgba(16, 16, 16, 0.8);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
padding: 6px;
font-size: 0.75rem;
width: 100%;
background-color: #fff;
padding: 4px 8px;
color: #212529;
}
.message-send,
.action-primary {
background-color: #337ab7;
color: #fff;
}
.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;
font-size: 0.7rem;
cursor: pointer;
}
.message-tabs button.active {
background: rgba(201, 164, 90, 0.2);
.message-send:hover {
background-color: #375a7f;
}
.message-tabs .refresh {
margin-left: auto;
.message-send:focus,
.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;
flex-direction: column;
gap: 8px;
min-height: 25px;
align-items: center;
outline: 1px solid gray;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
color: #fff;
}
.message-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
font-size: 0.75rem;
.header-label {
flex: 1;
}
.message-item .time {
margin-top: 4px;
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.6);
}
.message-response {
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;
.btn-more-small {
margin: 1px;
border: 1px solid transparent;
border-radius: 3px;
padding: 2px 6px;
font-size: 11.2px;
line-height: 1.5;
cursor: pointer;
}
.message-response .accept {
color: #8fd18f;
.action-secondary {
border-color: #6c757d;
background-color: #6c757d;
color: #fff;
}
.message-response .decline {
color: #e09a9a;
.btn-more-small:disabled {
cursor: default;
opacity: 0.65;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.5;
.empty-message {
min-height: 22px;
}
.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 {
border: 1px dashed rgba(201, 164, 90, 0.3);
padding: 6px;
font-size: 0.7rem;
cursor: pointer;
background-color: #6c757d;
}
.empty {
color: rgba(232, 221, 196, 0.6);
@media (min-width: 940px) {
.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>
@@ -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 CurrentCityView from '../views/CurrentCityView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationSecretView from '../views/NationSecretView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
import NationStratFinanView from '../views/NationStratFinanView.vue';
import ChiefCenterView from '../views/ChiefCenterView.vue';
@@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue';
import BettingView from '../views/BettingView.vue';
import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import BoardView from '../views/BoardView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
@@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue';
import YearbookView from '../views/YearbookView.vue';
import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue';
import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -148,6 +149,12 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/nation/secret',
name: 'nation-secret',
component: NationSecretView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/nation/personnel',
name: 'nation-personnel',
@@ -190,7 +197,6 @@ const routes = [
component: BattleSimulatorView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
@@ -252,6 +258,11 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/traffic',
name: 'traffic',
component: TrafficView,
},
{
path: '/npc-list',
name: 'npc-list',
@@ -277,8 +288,7 @@ const routes = [
},
{
path: '/my-settings',
name: 'my-settings',
component: MySettingsView,
redirect: '/my-page',
meta: {
requiresAuth: 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 CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.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 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 commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
const messageContacts = ref<MessageContacts | null>(null);
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
const messageDraftText = ref('');
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
let initializedMailboxGeneralId: number | null = null;
const general = computed(() => generalContext.value?.general ?? null);
const city = computed(() => generalContext.value?.city ?? null);
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} as const;
});
const mailboxOptions = computed(() => {
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
const mailboxGroups = computed(() => {
type MailboxOption = {
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 });
} else {
options.push({ label: '국가', value: -1, disabled: true });
if (permission >= 4) {
groups.push({
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 });
return options;
const sortedContacts = [...contacts].sort((left, right) => {
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(() => {
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
context.general.nationId > 0 && context.general.officerLevel >= 5
? trpc.turns.reserved.getNation.query({ generalId: id })
: Promise.resolve(null);
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
mapLayout.value = layout;
lobbyInfo.value = lobby;
worldMap.value = map;
commandTable.value = commands;
messages.value = messageData;
messageContacts.value = contacts;
boardAccess.value = access;
reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns;
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
try {
messageDraftText.value = '';
await trpc.messages.send.mutate({
generalId: id,
mailbox,
text,
});
messageDraftText.value = '';
await refreshMessages();
} catch (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 id = generalId.value;
if (!id) {
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
selectedCity,
commandTable,
messages,
messageContacts,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
setRealtimeEnabled,
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
sendMessage,
loadOlderMessages,
respondToMessage,
readLatestMessage,
deleteMessage,
setGeneralTurn,
shiftGeneralTurns,
setNationTurn,
+180 -56
View File
@@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
@@ -269,7 +268,26 @@ onMounted(() => {
</PanelCard>
<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>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -286,12 +304,7 @@ onMounted(() => {
<SkeletonLines v-if="loading || logLoading" :lines="3" />
<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"
/>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
@@ -303,126 +316,237 @@ onMounted(() => {
<style scoped>
.battle-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
min-height: 100vh;
padding: 24px;
margin: 0 auto;
padding: 0;
display: flex;
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 {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 0 8px;
border: 1px solid #666;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
font-size: 17px;
font-weight: 500;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
display: none;
}
.header-actions {
position: absolute;
left: 0;
top: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 4px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
display: contents;
}
.selector-row {
display: grid;
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto;
gap: 8px;
grid-template-columns: 8.333% 33.333% 50% 8.333%;
gap: 0;
align-items: center;
}
.select-input {
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
height: 36px;
padding: 4px 6px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
font-size: 0.85rem;
font: inherit;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
min-height: 32px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
padding: 4px 8px;
font: inherit;
cursor: pointer;
}
.general-meta {
margin-top: 10px;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.75);
margin: 0;
padding: 6px 8px;
color: #ccc;
display: grid;
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;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
grid-template-columns: repeat(6, 1fr);
}
.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 {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
border: 1px solid #666;
padding: 0;
background: #111;
min-height: 180px;
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
min-height: 34px;
margin: 0;
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 {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
}
.log-line:last-child {
border-bottom: none;
padding: 2px 8px;
border-bottom: 0;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
padding: 2px 8px;
color: #999;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
padding: 5px 8px;
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 {
grid-template-columns: 1fr;
}
.selector-row {
grid-template-columns: 16.666% 25% 41.666% 16.666%;
}
.log-grid {
grid-template-columns: 1fr;
}
}
@@ -26,6 +26,7 @@ type BattleExport = {
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const loading = ref(true);
const error = ref<string | null>(null);
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
const generalList = ref<GeneralListResponse | null>(null);
const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(null);
const gameDefaults = ref<GeneralMeResponse>(null);
let generalIdSeed = 0;
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
try {
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
options.value = context;
gameDefaults.value = me;
year.value = context.world.currentYear;
month.value = context.world.currentMonth;
repeatCnt.value = 1;
@@ -283,6 +286,47 @@ const initializeDefaults = async () => {
}
};
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
const applyGameEnvironment = () => {
if (!options.value) {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
defenderNation.tech = attackerNation.tech;
attackerCity.level = me?.city?.level ?? 5;
defenderCity.level = attackerCity.level;
defenderCity.def = me?.city?.defence ?? 1000;
defenderCity.wall = me?.city?.wall ?? 1000;
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
defenderNation.isCapital = attackerNation.isCapital;
error.value = null;
};
const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
repeatCnt.value = 1;
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
attackerCity.level = 5;
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
error.value = null;
};
onMounted(() => {
void initializeDefaults();
});
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
}
isSimulating.value = true;
error.value = null;
if (action === 'battle') {
battleResult.value = null;
}
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
try {
const payload = buildBattlePayload(action);
const response = await trpc.battle.simulate.mutate(payload);
const result =
'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId);
'payload' in response && response.payload
? response.payload
: await waitForSimulationResult(response.jobId);
if (!result.result) {
error.value = result.reason || 'battle_failed';
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
};
const openImportModal = async (target: GeneralDraft) => {
if (!hasGameGeneral.value) {
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
return;
}
importTarget.value = target;
importOpen.value = true;
if (!generalList.value) {
@@ -801,47 +855,62 @@ const closeImportModal = () => {
importTarget.value = null;
};
const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
const response = await trpc.battle.getGeneralDetail.query({ generalId });
applyGeneralExport(target, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id);
};
const applyMyGeneralToAttacker = async () => {
const generalId = gameDefaults.value?.general?.id;
if (!attackerGeneral.value || !generalId) {
error.value = '불러올 내 장수가 없습니다.';
return;
}
try {
error.value = null;
await applyServerGeneral(attackerGeneral.value, generalId);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const confirmImport = async () => {
if (!importTarget.value || !selectedGeneralId.value) {
return;
}
try {
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
applyGeneralExport(importTarget.value, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
importTarget.value.no =
importTarget.value === attackerGeneral.value
? 1
: resolveGeneralNo(response.general.no, importTarget.value.id);
await applyServerGeneral(importTarget.value, selectedGeneralId.value);
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
{
label: '준 피해',
value: battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
value:
battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
},
{
label: '받은 피해',
value: battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
value:
battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
},
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
</header>
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<div>
<strong>게임 상태와 분리된 모의 계산</strong>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
</p>
</div>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
현재 게임 환경 적용
</button>
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
독립 기본값
</button>
<button
class="ghost"
type="button"
:disabled="!hasGameGeneral || !attackerGeneral"
@click="applyMyGeneralToAttacker"
>
장수를 출병자로
</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
:options="options!"
mode="attacker"
title="출병자 설정"
:can-import-server="hasGameGeneral"
@import="openImportModal(attackerGeneral!)"
@save="saveGeneral(attackerGeneral!)"
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
:options="options!"
mode="defender"
:title="`수비자 설정 ${index + 1}`"
:can-import-server="hasGameGeneral"
@import="openImportModal(defender)"
@save="saveGeneral(defender)"
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
<div v-else class="select-wrap">
<select v-model.number="selectedGeneralId">
<optgroup
v-for="group in generalGroups"
:key="group.nation.id"
:label="group.nation.name"
>
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
<option
v-for="general in group.generals"
:key="general.id"
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, 1000px);
margin: 0 auto;
padding-bottom: 30px;
background:
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
gap: 8px;
}
.independence-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid rgba(112, 170, 141, 0.45);
background: rgba(18, 52, 40, 0.35);
}
.independence-notice strong {
color: #bfe2cd;
font-size: 0.85rem;
}
.independence-notice p {
margin: 4px 0 0;
color: rgba(221, 239, 228, 0.75);
font-size: 0.75rem;
}
.notice-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
}
button {
font-family: inherit;
background: none;
@@ -1230,6 +1355,11 @@ button {
background: rgba(16, 16, 16, 0.6);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
@@ -1369,5 +1499,10 @@ button {
flex-direction: column;
align-items: flex-start;
}
.independence-notice {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+292 -61
View File
@@ -1,5 +1,7 @@
<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';
type RankEntry = {
@@ -11,7 +13,6 @@ type RankEntry = {
fgColor: string;
picture: string | null;
imageServer: number;
value: number;
printValue: string;
};
@@ -21,18 +22,17 @@ type RankSection = {
entries: RankEntry[];
};
type UniqueOwner = {
id: number;
name: string;
nationName: string;
bgColor: string;
fgColor: string;
type UniqueItemEntry = {
itemKey: string;
itemName: string;
itemInfo: string;
owner: Omit<RankEntry, 'ownerName' | 'printValue'>;
};
type UniqueItemSection = {
title: string;
slot: string;
owners: UniqueOwner[];
entries: UniqueItemEntry[];
};
type BestGeneralPayload = {
@@ -41,12 +41,26 @@ type BestGeneralPayload = {
uniqueItems: UniqueItemSection[];
};
const router = useRouter();
const viewMode = ref<'user' | 'npc'>('user');
const loading = ref(false);
const errorMessage = ref('');
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;
errorMessage.value = '';
try {
@@ -58,8 +72,6 @@ const refresh = async () => {
}
};
const emptyLabel = computed(() => (loading.value ? '불러오는 중...' : '표시할 데이터가 없습니다.'));
onMounted(() => {
void refresh();
});
@@ -70,61 +82,280 @@ watch(viewMode, () => {
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">명장일람</h1>
<p class="page-subtitle">전장 기록을 기준으로 장수 순위를 확인합니다.</p>
</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>
<main id="best-general-container" class="legacy-ranking-page legacy-bg0">
<div class="legacy-ranking-title">
<br />
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="!data" class="placeholder">{{ emptyLabel }}</div>
<div class="view-selector" role="group" aria-label="장수 유형">
<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-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4">
<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>
<ul v-else class="space-y-2">
<li
v-for="entry in section.entries"
:key="entry.id"
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm"
>
<div class="flex items-center gap-2">
<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 v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message">불러오는 중...</div>
<section v-if="data" class="ranking-sections" :aria-busy="loading">
<article v-for="section in data.sections" :key="section.title" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.id}:${rank}`">
<div class="hall-rank legacy-bg2">{{ rank + 1 }}</div>
<div class="hall-img">
<img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
</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>
</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 v-if="data" class="mt-6 bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 class="text-base font-semibold mb-3">유니크 아이템 소유자</h2>
<div class="grid md:grid-cols-2 gap-4">
<div v-for="item in data.uniqueItems" :key="item.title" class="bg-zinc-950 border border-zinc-800 rounded p-3">
<h3 class="text-sm font-semibold mb-2">{{ item.title }}</h3>
<ul class="space-y-1 text-xs">
<li v-for="owner in item.owners" :key="owner.id" class="flex items-center gap-2">
<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>
<div class="legacy-ranking-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</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>
</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">
import { computed, onMounted, ref } from 'vue';
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc';
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 error = ref('');
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 sequence = ++loadSequence;
try {
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
selected.value = data.value.city.id;
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
if (sequence !== loadSequence) return;
data.value = result;
selected.value = result.city.id;
error.value = '';
} catch (cause) {
if (sequence !== loadSequence) return;
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);
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>
<template>
<main class="city-page">
<table class="legacy-table legacy-bg0 center">
<table class="legacy-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
<td> <br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
@@ -34,32 +112,57 @@ onMounted(() => void load());
<tbody>
<tr>
<td>
도시선택 :
<select v-model.number="selected" @change="load(selected)">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
<form @submit.prevent="selectCity">
<div>
도시선택 :
<select id="citySelector" v-model.number="selected" @change="selectCity">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
</div>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
</form>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<template v-if="data && city">
<table class="legacy-table legacy-bg2 stats">
<table class="legacy-table legacy-bg0 back-row">
<tbody>
<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 }}
</td>
<td class="city-title">{{ data.lastExecute }}</td>
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
</tr>
<tr>
<th>주민</th>
@@ -79,15 +182,9 @@ onMounted(() => void load());
<th>민심</th>
<td>{{ show(city.trust) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '-' }}%</td>
<td>{{ city.trade ?? '- ' }}%</td>
<th>인구</th>
<td>
{{
city.population === null
? '?'
: ((city.population / city.populationMax) * 100).toFixed(2)
}}%
</td>
<td>{{ populationRate }}%</td>
<th>태수</th>
<td>{{ city.officers[4] }}</td>
<th>군사</th>
@@ -95,19 +192,60 @@ onMounted(() => void load());
<th>종사</th>
<td>{{ city.officers[2] }}</td>
</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>
<th>장수</th>
<td colspan="11">
{{
data.visibility.detailed
? data.generals.map((g) => g.name).join(', ') || '-'
: '알 수 없음'
}}
<td colspan="11" class="general-names">
<template v-if="data.visibility.detailed">
<template v-if="data.generals.length">
<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>
</tr>
</tbody>
</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>
<tr>
<th> </th>
@@ -125,42 +263,53 @@ onMounted(() => void load());
</tr>
</thead>
<tbody>
<tr v-for="general in data.generals" :key="general.id">
<td>
<img
v-if="general.picture"
width="64"
height="64"
:src="`/image/general/${general.picture}`"
/>
<tr
v-for="general in data.generals"
:key="general.id"
:data-is-our-general="general.train !== null"
:data-general-wounded="general.injury"
>
<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>{{ general.name }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ general.defenceTrain ?? '?' }}</td>
<td>{{ general.crewTypeId ?? '?' }}</td>
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
<td>{{ general.crewTypeName ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns">
{{
general.turns.length
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
: general.npcState > 1
? 'NPC 장수'
: `${general.nationName}】 장수`
}}
<template v-if="general.turns.length">
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
>{{ index + 1 }} : {{ turn }}</span
>
</template>
<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>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 center footer">
<table class="legacy-table legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
@@ -170,60 +319,138 @@ onMounted(() => void load());
<style scoped>
.city-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
margin: 8px auto 0;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 2px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
border: 0;
padding: 1px;
font-weight: 400;
}
.center {
.center,
.selector,
.stats td,
.stats th,
.generals th,
.generals td:not(:last-child) {
text-align: center;
}
.selector {
text-align: center;
margin-top: 0;
}
.selector select {
display: inline-block;
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 {
margin-top: 14px;
margin-top: 0;
table-layout: fixed;
}
.label-col {
width: 48px;
}
.value-col {
width: 108px;
}
.first-value-col {
width: 112px;
}
.stats th,
.generals th {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
text-align: center;
}
.stats td {
text-align: center;
}
.city-title {
text-align: center;
}
.generals {
margin-top: 14px;
.stats {
height: 136px;
}
.generals td {
text-align: center;
.general-names {
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 {
text-align: left;
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 {
font-size: x-small;
}
.turn-line {
display: block;
}
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.footer {
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 {
text-align: center;
color: #ff7373;
@@ -233,8 +460,5 @@ onMounted(() => void load());
width: 1000px;
transform-origin: top left;
}
.selector select {
min-width: 300px;
}
}
</style>
+45 -5
View File
@@ -135,7 +135,7 @@ onMounted(async () => {
</select>
</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="!data" class="legacy-message">표시할 데이터가 없습니다.</div>
@@ -171,6 +171,10 @@ onMounted(async () => {
<div class="legacy-hall-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</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>
</template>
@@ -191,7 +195,7 @@ onMounted(async () => {
.legacy-hall-title,
.legacy-hall-bottom {
text-align: center;
text-align: left;
}
.legacy-hall-title {
@@ -205,12 +209,33 @@ onMounted(async () => {
}
.scenario-search select {
min-width: 220px;
width: 189px;
height: 20px;
border: 1px solid #555;
background: #ddd;
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 {
border: 1px solid gray;
padding: 12px;
@@ -221,6 +246,15 @@ onMounted(async () => {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.hall-sections {
display: block;
}
@@ -235,7 +269,9 @@ onMounted(async () => {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: 1.17em;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
@@ -275,7 +311,7 @@ onMounted(async () => {
display: inline-block;
width: 64px;
height: 64px;
object-fit: cover;
object-fit: fill;
}
.hall-server,
@@ -309,5 +345,9 @@ onMounted(async () => {
.legacy-hall-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+545 -448
View File
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
@@ -12,8 +10,8 @@ type BuffKey =
| 'warAvoidRatio'
| 'warCriticalRatio'
| 'warMagicTrialProb'
| 'success'
| 'fail'
| 'domesticSuccessProb'
| 'domesticFailProb'
| 'warAvoidRatioOppose'
| 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose';
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'success',
'fail',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
success: '내정 성공률 증가',
fail: '내정 실패 감소',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패 확률 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
const pointLabels: Record<string, string> = {
previous: '보유',
lived_month: '생존 턴',
max_domestic_critical: '내정 최고치',
active_action: '활동',
combat: '전투',
sabotage: '계략',
dex: '숙련',
unifier: '통일 보상',
max_domestic_critical: '최대 연속 내정 성공',
active_action: '능동 행동 수',
combat: '전투 횟수',
sabotage: '계략 성공 횟수',
dex: '숙련',
unifier: '천통 기여',
tournament: '토너먼트',
betting: '베팅',
max_belong: '최대 충성',
betting: '베팅 당첨',
max_belong: '최대 임관년 수',
};
const pointOrder = [
'previous',
'lived_month',
'max_belong',
'max_domestic_critical',
'active_action',
'combat',
@@ -65,9 +64,33 @@ const pointOrder = [
'unifier',
'tournament',
'betting',
'max_belong',
] 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 error = ref<string | null>(null);
const status = ref<InheritStatus | null>(null);
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
warAvoidRatio: 1,
warCriticalRatio: 1,
warMagicTrialProb: 1,
success: 1,
fail: 1,
domesticSuccessProb: 1,
domesticFailProb: 1,
warAvoidRatioOppose: 1,
warCriticalRatioOppose: 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 resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
const resetStatCost = computed(() =>
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
);
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 map = new Map<string, string>();
for (const entry of status.value?.availableSpecialWar ?? []) {
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
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 points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
const current = status.value?.buffLevels[key] ?? 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 => {
if (value instanceof Error) {
return value.message;
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
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 = () => {
if (!status.value) {
return;
@@ -235,6 +233,14 @@ const syncSelections = () => {
if (!uniqueForm.amount) {
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 () => {
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
const openUniqueAuction = async () => {
if (!uniqueForm.itemId.trim()) {
actionError.value = '유니크 아이템 ID를 입력해주세요.';
actionError.value = '유니크를 선택해주세요.';
return;
}
const amount = Math.max(0, Math.floor(uniqueForm.amount));
@@ -412,12 +418,6 @@ const checkOwner = async () => {
});
};
watch(statRules, (rules) => {
if (rules) {
applyResetBalanced();
}
});
onMounted(() => {
void loadStatus();
void loadJoinConfig();
@@ -426,464 +426,561 @@ onMounted(() => {
</script>
<template>
<main class="inherit-page">
<header class="inherit-header">
<div>
<h1 class="inherit-title">유산 포인트</h1>
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
</div>
<div class="inherit-actions">
<button class="ghost" @click="loadStatus">새로고침</button>
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
</div>
</header>
<header class="top-back-bar legacy-bg0">
<RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
<strong>유산 관리</strong>
<button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
</header>
<div v-if="error" class="inherit-error">{{ error }}</div>
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
<main id="container" class="inherit-page legacy-bg0">
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
<div v-if="loading" class="loading-state">불러오는 중...</div>
<div v-if="loading">
<SkeletonLines :lines="4" />
</div>
<template v-else-if="status">
<section id="inheritance_list" class="point-grid">
<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">
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
<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>
<section id="inheritance_store">
<h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
<div v-else class="buff-list">
<div v-for="key in buffKeys" :key="key" class="buff-row">
<div class="buff-info">
<div class="buff-name">{{ buffLabels[key] }}</div>
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
</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 }} 단계
<div class="action-grid leading-actions">
<article class="shop-item">
<div class="control-row">
<label for="next-special">다음 전투 특기 선택</label>
<select id="next-special" v-model="nextSpecialKey">
<option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
{{ entry.name }}
</option>
</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
: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)"
>
구입
</button>
</div>
</div>
</article>
</div>
</PanelCard>
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<label class="form-field">
<span>다음 전투 특기</span>
<select v-model="nextSpecialKey" class="form-input">
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
{{ special.name }}
<div class="divider"></div>
<div class="action-grid bottom-actions">
<article class="shop-item">
<div class="control-row">
<label for="owner-target">장수 소유자 확인</label>
<select id="owner-target" v-model="ownerTargetId">
<option disabled value="">장수 선택</option>
<option
v-for="general in status.availableTargetGenerals"
:key="general.id"
:value="String(general.id)"
>
{{ general.name }}
</option>
</select>
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
</div>
<div class="action-row">
<div>
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
<div class="muted">
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
({{ status.resetLevels.resetSpecialWar }})
</div>
<small
>장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
></small
>
<button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
소유자 찾기
</button>
<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>
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
</div>
<small
>시즌 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>
</PanelCard>
</section>
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
<div v-if="!status" class="muted"> 시간 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<div class="muted">
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }})
</div>
<button :disabled="isUnited || actionBusy" @click="resetTurnTime"> 시간 변경</button>
</div>
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
<section class="inherit-logs">
<h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
<span>{{ entry.text }}</span>
</div>
</PanelCard>
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
<div v-else class="stat-panel">
<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>
<button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
가져오기
</button>
</section>
</template>
</main>
</template>
<style scoped>
.inherit-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
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 {
.top-back-bar {
width: min(100%, 1000px);
min-height: 38px;
margin: 0 auto;
border: 1px solid #888;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.summary-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.summary-head {
display: flex;
grid-template-columns: 100px 1fr 100px;
align-items: center;
justify-content: space-between;
gap: 8px;
text-align: center;
padding: 3px 6px;
box-sizing: border-box;
}
.summary-total {
font-size: 1rem;
font-weight: 600;
}
.summary-state {
font-size: 0.75rem;
.top-button {
padding: 4px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
text-decoration: none;
}
.summary-state.united {
border-color: rgba(240, 120, 120, 0.6);
color: rgba(240, 150, 150, 0.9);
.inherit-page {
width: min(100%, 1000px);
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 {
display: flex;
flex-direction: column;
gap: 4px;
.notice,
.loading-state,
.log-empty {
padding: 10px;
text-align: center;
}
.summary-row {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
.notice.error {
color: #ffb0b0;
}
.summary-footer {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
.notice.success {
color: #b6efb6;
}
.buff-list {
display: flex;
flex-direction: column;
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 {
.point-grid,
.action-grid,
.buff-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-summary {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
display: flex;
flex-direction: column;
gap: 4px;
.inherit-item,
.shop-item {
padding: 8px 16px;
box-sizing: border-box;
min-width: 0;
}
.stat-errors {
color: rgba(240, 150, 150, 0.9);
.inherit-item {
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;
flex-direction: column;
gap: 6px;
font-size: 0.75rem;
}
.form-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(10, 10, 10, 0.8);
padding: 6px 8px;
color: inherit;
.shop-item .buy-button {
width: 50%;
margin-left: auto;
}
button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
background: rgba(16, 16, 16, 0.6);
color: inherit;
.simple-item small {
min-height: 55px;
}
button.ghost {
background: transparent;
.buff-item small {
min-height: 72px;
}
.log-list {
display: flex;
flex-direction: column;
.dual-buttons {
display: grid;
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;
text-align: right;
}
.log-entry {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8rem;
.stat-layout > div {
display: grid;
gap: 3px;
}
.log-date {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
.stat-layout label {
display: grid;
grid-template-columns: 22px 1fr;
align-items: center;
}
.log-footer {
margin-top: 8px;
.stat-layout strong {
text-align: left;
font-size: 12px;
}
.muted {
color: rgba(232, 221, 196, 0.6);
font-size: 0.75rem;
.owner-result {
margin: 0;
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>
+13 -10
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
@@ -281,6 +281,7 @@ onMounted(() => {
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
</div>
@@ -464,17 +465,13 @@ onMounted(() => {
<section v-else class="join-grid">
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
<template #actions>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
목록 새로고침
</button>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
</template>
<div v-if="npcError" class="muted">{{ npcError }}</div>
<div v-if="npcLoading && npcCandidates.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">
빙의 가능한 NPC가 없습니다.
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
<div v-else class="npc-list">
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<div class="npc-header">
@@ -490,9 +487,7 @@ onMounted(() => {
<div>나이 {{ npc.age }}</div>
<div>도시 {{ npc.city?.name ?? '-' }}</div>
</div>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
빙의
</button>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
</div>
</div>
<div class="npc-footer">
@@ -542,6 +537,14 @@ onMounted(() => {
font-size: 0.8rem;
}
.simulator-link {
border: 1px solid rgba(112, 170, 141, 0.55);
padding: 6px 10px;
color: #bfe2cd;
font-size: 0.8rem;
text-decoration: none;
}
.join-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
+57 -34
View File
@@ -50,7 +50,7 @@ const {
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
} = storeToRefs(dashboard);
@@ -104,6 +104,10 @@ watch(
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</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="/troop">부대 편성</RouterLink>
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
@@ -115,10 +119,11 @@ watch(
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<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
>
@@ -216,22 +221,26 @@ watch(
</div>
<div v-if="mobileTab === 'messages'" class="mobile-panel">
<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>
<MessagePanel
class="mobile-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"
/>
</div>
</section>
@@ -251,22 +260,6 @@ watch(
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
</div>
</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 class="stack">
@@ -302,6 +295,26 @@ watch(
<div v-else class="placeholder">개인 기록 영역</div>
</PanelCard>
</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>
</main>
</template>
@@ -396,6 +409,16 @@ button {
gap: 16px;
}
.desktop-message-panel {
grid-column: 1 / -1;
}
.mobile-message-panel {
width: 100vw;
min-width: 0;
margin-left: -24px;
}
.layout-mobile {
display: flex;
flex-direction: column;
+543 -467
View File
@@ -1,19 +1,16 @@
<script setup lang="ts">
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 { 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 WorldStateSnapshot = {
type WorldSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
meta: Record<string, unknown>;
} | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type LogLine = {
id: number;
html: string;
};
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: '개인 기록',
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const screenMode = ref<ScreenMode>('auto');
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: [],
battleDetail: [],
battleResult: [],
generalAction: [],
});
const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false,
battleDetail: false,
battleResult: false,
generalAction: false,
});
const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true,
battleDetail: true,
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
generalAction: true,
});
const activeLogTab = ref<LogType>('generalAction');
const isMobile = useMediaQuery('(max-width: 1024px)');
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const numberValue = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const resolveNumber = (value: unknown, fallback: number): number => {
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 statusLine = computed(() =>
world.value
? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
1,
Math.round(world.value.tickSeconds / 60)
)}분 턴`
: '내 정보를 불러오는 중'
);
const statusLine = computed(() => {
if (!worldState.value) {
return '내 정보를 불러오는 중';
}
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : '';
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 canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
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 },
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.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 general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
const npcMode = resolveNumber(config.npcMode, 0);
const meta = world.value?.meta ?? {};
const config = world.value?.config ?? {};
const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false),
canInstantRetreat: Boolean(general && general.nationId > 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
instantRetreat: Boolean(availableInstantAction.instantRetreat),
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const loadLogs = async () => {
if (!data.value?.general?.id) {
return;
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
await Promise.all(
logTypes.map((type) => loadLog(type))
);
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) {
return;
}
if (logLoading[type]) return;
logLoading[type] = true;
try {
const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
logs[type] = beforeId ? [...logs[type], ...next] : next;
logHasMore[type] = next.length >= 24;
} catch (cause) {
error.value = errorText(cause);
} finally {
logLoading[type] = false;
}
};
const loadMyPage = async () => {
if (loading.value) {
return;
}
const loadPage = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
const [general, state] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
]);
data.value = general;
worldState.value = world ?? null;
await loadLogs();
} catch (err) {
error.value = resolveErrorMessage(err);
world.value = state;
if (general) {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
} catch (cause) {
error.value = errorText(cause);
} finally {
loading.value = false;
}
};
const confirmAction = async (message: string, action: () => Promise<void>) => {
if (!confirm(message)) {
return;
}
const saveSettings = async () => {
if (!canSave.value) return;
try {
await action();
await loadMyPage();
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
await trpc.general.setMySetting.mutate({ ...form });
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
const handleDieOnPrestart = () =>
confirmAction('정말로 삭제하시겠습니까?', async () => {
await trpc.general.dieOnPrestart.mutate();
window.location.reload();
});
const handleBuildNationCandidate = () =>
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';
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
watch(
() => isMobile.value,
(value) => {
if (value && !logTypes.includes(activeLogTab.value)) {
activeLogTab.value = 'generalAction';
}
}
);
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
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(() => {
refreshScreenMode();
void loadMyPage();
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main class="my-page" :class="`screen-${screenMode}`">
<header class="page-header">
<div>
<h1 class="page-title"> 정보</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</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>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
<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">
<div class="stack">
<PanelCard title="장수 상태">
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="도시 상태">
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="세력 상태">
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
</PanelCard>
</div>
<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>
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<img
:src="
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
"
alt=""
/>
<strong>{{ data.general.name }}</strong>
</div>
</PanelCard>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
<div class="item-grid">
<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 -->
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
</div>
</div>
</PanelCard>
<div>
<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 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>
</main>
</template>
<style scoped>
.my-page {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
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;
}
.my-page.screen-500px {
.legacy-page.screen-500px {
max-width: 500px;
}
.my-page.screen-1000px {
.legacy-page.screen-1000px {
max-width: 1000px;
}
.page-header {
.title-row {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
}
.page-title {
font-size: 1.6rem;
.title-row > span {
flex-basis: 100%;
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;
text-decoration: none;
letter-spacing: 0;
}
.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;
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center;
}
.action-btn.link {
display: inline-flex;
align-items: center;
justify-content: center;
.error-row {
color: #ff7777;
border: 1px solid #a33;
}
.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;
}
.top-grid,
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
.general-column,
.settings-column,
.log-panel {
border: 1px solid #666;
background-image: url('/image/game/back_walnut.jpg');
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
.section-title,
.log-panel h2 {
min-height: 34px;
margin: 0;
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;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
.sky {
color: skyblue;
}
.log-line:last-child {
border-bottom: none;
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.log-more {
.portrait-cell {
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;
}
.log-tabs {
display: flex;
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;
@media (max-width: 991px) {
.legacy-page {
width: 500px;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
+131 -60
View File
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
});
const listItems = computed(() =>
list.value
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
: []
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
);
const info = computed(() => detail.value?.bettingInfo ?? null);
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
const totalAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
);
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
const pureAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce(
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
return result;
});
const usedAmount = computed(() =>
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
);
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const parseYearMonth = (yearMonth: number): [number, number] => [
Math.floor(yearMonth / 12),
(yearMonth % 12) + 1,
];
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
const readSelection = (value: string): number[] => {
try {
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
.map((index) => candidates.value[index]?.title ?? '-')
.join(', ');
const isListOpen = (item: BettingListItem): boolean =>
!item.finished && listYearMonth.value <= item.closeYearMonth;
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
const isDetailOpen = computed(() =>
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
};
const expectedMultiplier = (key: string, betAmount: number): string => {
if (betAmount <= 0) {
return '0.0';
const rewardByMatch = computed(() => {
const selectCount = info.value?.selectCnt ?? 0;
const rewards = new Array<number>(selectCount + 1).fill(0);
if (selectCount <= 0) {
return rewards;
}
const amountByMatch = new Map<number, number>();
for (const [key, betAmount] of detailRows.value) {
const matched = matchCount(key);
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
}
if (selectCount === 1 || info.value?.isExclusive) {
rewards[selectCount] = totalAmount.value;
return rewards;
}
let remainingReward = totalAmount.value;
let accumulatedReward = 0;
let nextReward = totalAmount.value;
for (let matched = selectCount; matched > 0; matched -= 1) {
nextReward /= 2;
accumulatedReward += nextReward;
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] = accumulatedReward;
remainingReward -= accumulatedReward;
accumulatedReward = 0;
}
for (let matched = selectCount; matched >= 0; matched -= 1) {
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] += remainingReward;
break;
}
return rewards;
});
const expectedReward = (key: string): number => {
if (!info.value?.finished) {
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
return (reward / betAmount).toFixed(1);
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
}
const matched = matchCount(key);
const matchedAmount = detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
.reduce((sum, [, value]) => sum + value, 0);
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
return rewardByMatch.value[matchCount(key)] ?? 0;
};
const rewardDivisor = (key: string, betAmount: number): number =>
info.value?.finished
? detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
.reduce((sum, [, value]) => sum + value, 0)
: betAmount;
const expectedMultiplier = (key: string, betAmount: number): string => {
const divisor = rewardDivisor(key, betAmount);
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
};
const myExpectedReward = (key: string, betAmount: number): string => {
const myAmount = myBetMap.value.get(key);
if (myAmount === undefined) {
return '';
}
const divisor = rewardDivisor(key, betAmount);
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
};
const loadList = async () => {
@@ -295,7 +338,7 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
<div></div>
<h1>국가 베팅장</h1>
<div></div>
<div></div>
@@ -309,32 +352,37 @@ onMounted(() => {
{{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="currentYearMonth <= info.closeYearMonth">
({{ parseYearMonth(info.closeYearMonth)[0] }}
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(info.closeYearMonth)[0] }} {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
</div>
<div class="betting-candidates">
<button
<div
v-for="(candidate, index) in candidates"
:key="`${info.id}-${index}`"
type="button"
class="betting-candidate"
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
class="betting-candidate-cell"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
<button
type="button"
class="betting-candidate"
:class="{
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
}"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
</div>
</div>
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
@@ -361,7 +409,7 @@ onMounted(() => {
{{ selectionLabel(key) }}
</div>
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
<div>{{ myExpectedReward(key, betAmount) }}</div>
<div>{{ expectedMultiplier(key, betAmount) }}</div>
</div>
</div>
@@ -382,8 +430,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.finished">(종료)</span>
<span v-else-if="isListOpen(item)">
({{ parseYearMonth(item.closeYearMonth)[0] }}
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(item.closeYearMonth)[0] }} {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
</button>
@@ -400,12 +447,11 @@ onMounted(() => {
.nation-betting-page {
position: relative;
width: 500px;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.3;
line-height: 1.5;
overflow-x: hidden;
}
@@ -458,19 +504,32 @@ onMounted(() => {
}
.section-title {
min-height: 22px;
min-height: 21px;
text-align: center;
line-height: 22px;
line-height: 21px;
}
.betting-candidates {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
margin-top: -3.5px;
margin-right: -1.75px;
margin-left: -1.75px;
}
.betting-candidate-cell {
flex: 0 0 auto;
width: 33.33333333%;
max-width: 100%;
padding-right: 1.75px;
padding-left: 1.75px;
margin-top: 3.5px;
}
.betting-candidate {
width: 100%;
height: 100%;
min-height: 143px;
min-width: 0;
padding: 0;
border: 1px solid gray;
@@ -530,7 +589,7 @@ onMounted(() => {
.betting-form input {
grid-column: span 4;
min-width: 0;
height: 30px;
height: 35.5px;
border: 1px solid #777;
background: #ddd;
color: #303030;
@@ -538,10 +597,11 @@ onMounted(() => {
.betting-form button {
grid-column: span 2;
height: 35.5px;
}
.payout-table {
margin-top: 6px;
margin-top: 0;
}
.payout-row {
@@ -551,7 +611,7 @@ onMounted(() => {
.payout-row > div {
min-width: 0;
padding: 2px 4px;
padding: 0 4px;
}
.payout-row > div:not(:first-child) {
@@ -572,7 +632,7 @@ onMounted(() => {
.betting-item {
display: block;
width: 100%;
width: auto;
margin: 0.25em;
border: 0;
background: transparent;
@@ -595,6 +655,7 @@ onMounted(() => {
.betting-footer .legacy-nav-button {
width: 90px;
height: 35.5px;
}
.betting-notice,
@@ -602,6 +663,15 @@ onMounted(() => {
padding: 6px 10px;
}
.betting-notice {
position: fixed;
top: 8px;
right: 8px;
z-index: 20;
width: min(320px, calc(100vw - 16px));
background: #303030;
}
.betting-notice.error {
border: 1px solid #9b4848;
color: #ffd0d0;
@@ -617,8 +687,9 @@ onMounted(() => {
width: 1000px;
}
.betting-candidates {
grid-template-columns: repeat(6, minmax(0, 1fr));
.betting-candidate-cell {
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
width: 16.66666667%;
}
.betting-form {
+199 -322
View File
@@ -1,349 +1,226 @@
<script setup lang="ts">
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 { trpc } from '../utils/trpc';
type GeneralListResponse = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type GeneralEntry = GeneralListResponse['generals'][number];
type SortKey =
| 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' },
];
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['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);
const error = ref('');
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<GeneralListResponse | null>(null);
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) {
return;
}
const sort = ref<Sort>(1);
const options = [
'관직',
'계급',
'명성',
'통솔',
'무력',
'지력',
'자금',
'군량',
'병사',
'벌점',
'성격',
'내특',
'전특',
'사관',
'NPC',
];
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const load = async () => {
loading.value = true;
error.value = null;
error.value = '';
try {
data.value = await trpc.nation.getGeneralList.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const sortGenerals = (list: GeneralEntry[]): GeneralEntry[] => {
const key = sortKey.value;
const sorted = [...list].sort((lhs, rhs) => {
switch (key) {
case 1:
return rhs.officerLevel - lhs.officerLevel;
case 2:
return rhs.dedication - lhs.dedication;
case 3:
return rhs.experience - lhs.experience;
case 4:
return rhs.stats.leadership - lhs.stats.leadership;
case 5:
return rhs.stats.strength - lhs.stats.strength;
case 6:
return rhs.stats.intelligence - lhs.stats.intelligence;
case 7:
return rhs.gold - lhs.gold;
case 8:
return rhs.rice - lhs.rice;
case 9:
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();
});
const generals = computed(() =>
[...(data.value?.generals ?? [])].sort((a, b) => {
if (sort.value === 1) return b.officerLevel - a.officerLevel || a.id - b.id;
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
return a.id - b.id;
})
);
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
onMounted(load);
</script>
<template>
<main class="nation-page">
<header class="page-header">
<div>
<h1 class="page-title">세력 장수</h1>
<p class="page-subtitle">세력 장수 현황 정렬</p>
</div>
<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>
<main class="general-page legacy-bg0">
<header>
<strong>세력 장수</strong>
<span
><RouterLink to="/">돌아가기</RouterLink>
<button :disabled="loading" @click="load">새로고침</button></span
>
</header>
<div v-if="error" class="error">{{ error }}</div>
<PanelCard title="세력 장수 목록" subtitle="국가 소속 장수들을 확인합니다.">
<template #actions>
<div class="toolbar-actions">
<select v-model.number="sortKey" class="select-input">
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</select>
<input v-model="filterText" class="filter-input" placeholder="이름/도시 검색" />
</div>
</template>
<div class="list-meta"> {{ filteredGenerals.length }}</div>
<SkeletonLines v-if="loading" :lines="6" />
<div v-else class="table-scroll">
<table class="nation-table">
<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>특기</th>
<th>사관</th>
<th>현재 도시</th>
<th>관직 도시</th>
</tr>
</thead>
<tbody>
<tr v-for="general in filteredGenerals" :key="general.id">
<td>
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
{{ general.name }}
</td>
<td>{{ formatOfficerLevelText(general.officerLevel, nationLevel) }}</td>
<td>{{ general.dedication }}</td>
<td>{{ general.experience }}</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.personality?.name ?? '-' }}</td>
<td>{{ formatSpecial(general) }}</td>
<td>{{ general.belong > 0 ? general.belong : '-' }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.officerCityName ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
</PanelCard>
<section class="sort">
정렬순서 :
<select v-model.number="sort" aria-label="세력 장수 정렬">
<option v-for="(label, index) in options" :key="label" :value="index + 1">{{ label }}</option>
</select>
<button>정렬하기</button>
<small v-if="data">열람 등급 {{ data.viewer.permission }}</small>
</section>
<p v-if="error" class="state error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="scroll">
<table id="nation-general-list">
<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>사관</th>
<th>벌점</th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td :class="`npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>
{{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}
</td>
<td>
Lv {{ general.experienceLevel }}<br />{{
general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'
}}
</td>
<td>{{ general.gold.toLocaleString() }}</td>
<td>{{ general.rice.toLocaleString() }}</td>
<td>{{ general.cityName ?? '?' }}</td>
<td>{{ general.troopName ?? '?' }}</td>
<td>{{ visibleCrew(general)?.toLocaleString() ?? '?' }}</td>
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
<td
:title="
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
"
>
{{ special(general) }}
</td>
<td>{{ general.belong }}</td>
<td>{{ general.refreshScoreTotal }}</td>
</tr>
</tbody>
</table>
</div>
<footer><RouterLink to="/">돌아가기</RouterLink></footer>
</main>
</template>
<style scoped>
.nation-page {
.general-page {
width: 1000px;
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;
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;
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;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
color: #ccc;
}
.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>
@@ -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">
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 { trpc } from '../utils/trpc';
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type PolicyKey = keyof NationPolicy;
type PolicyField = {
key: PolicyKey;
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
type PrioritySectionKey = 'nation' | 'general';
type PriorityBucket = 'active' | 'inactive';
interface PolicyField {
key: NumericPolicyKey;
label: string;
step: number;
description: string;
hint?: string;
percent?: boolean;
};
min?: number;
max?: number;
}
type PolicySection = {
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 = {
interface PriorityListState {
active: string[];
inactive: 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 error = ref<string | null>(null);
const notice = ref<string | null>(null);
const data = ref<NpcPolicyResponse | null>(null);
const policyDraft = ref<NationPolicy | null>(null);
const lastSavedPolicy = ref<NationPolicy | null>(null);
const nationPriority = ref<PriorityListState | null>(null);
const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const dragState = ref<DragState | null>(null);
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Error) return value.message;
if (typeof value === 'string') return value;
return 'unknown_error';
};
@@ -85,851 +68,870 @@ const clonePolicy = (source: NationPolicy): NationPolicy => ({
const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
const activeSet = new Set(active);
const inactive = available.filter((item) => !activeSet.has(item));
return {
active: [...active],
inactive,
inactive: available.filter((item) => !activeSet.has(item)),
available: [...available],
};
};
const loadPolicy = async () => {
if (loading.value) {
return;
}
if (loading.value) return;
loading.value = true;
error.value = null;
try {
data.value = await trpc.npc.getPolicy.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} catch (caught) {
error.value = resolveErrorMessage(caught);
} finally {
loading.value = false;
}
};
onMounted(() => {
void loadPolicy();
watch(data, (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];
});
watch(
() => 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];
}
);
onMounted(() => void loadPolicy());
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
const calcPolicyValue = (key: NumericPolicyKey): number => {
if (!data.value || !policyDraft.value) {
return 0;
}
if (!data.value || !policyDraft.value) return 0;
const value = policyDraft.value[key];
if (value === 0) {
return data.value.zeroPolicy[key];
}
return value;
return value === 0 ? data.value.zeroPolicy[key] : value;
};
const safeRecruitPercent = computed({
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
set: (value: number) => {
if (!policyDraft.value) {
return;
}
policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
if (policyDraft.value) policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
},
});
const policySections = computed<PolicySection[]>(() => {
if (!data.value) {
return [];
}
const policyFields = computed<PolicyField[]>(() => {
if (!data.value) return [];
const statMax = data.value.defaultStatMax;
const statNpcMax = data.value.defaultStatNpcMax;
return [
{
title: '국가 재정',
fields: [
{
key: 'reqNationGold',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
],
key: 'reqNationGold',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
title: '유저 전투장',
fields: [
{
key: 'reqHumanWarUrgentGold',
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
)}입니다.`,
},
],
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
title: '유저 내정장',
fields: [
{
key: 'reqHumanDevelGold',
label: '권장 금',
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqHumanDevelRice',
label: '권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
],
key: 'reqHumanWarUrgentGold',
label: '유저전투장 긴급포상 금',
step: 100,
description: '유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100)} * 6) 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentGold)}입니다.`,
},
{
title: 'NPC 전투장',
fields: [
{
key: 'reqNPCWarGold',
label: '권장 금',
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
)}입니다.`,
},
],
key: 'reqHumanWarUrgentRice',
label: '유저전투장 긴급포상 쌀',
step: 100,
description: '유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100)} * 6명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentRice)}입니다.`,
},
{
title: 'NPC 내정장',
fields: [
{
key: 'reqNPCDevelGold',
label: '권장 금',
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCDevelGold
)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: '권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
],
key: 'reqHumanWarRecommandGold',
label: '유저전투장 권장 금',
step: 100,
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 유저전투장 긴급포상 금의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentGold') * 2)}입니다.`,
},
{
title: '자원 정책',
fields: [
{
key: 'minimumResourceActionAmount',
label: '포상/몰수/헌납 최소 단위',
step: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납 최대 단위',
step: 100,
description: '연산결과가 이 단위보다 크다면 이 값에 맞춥니다.',
},
],
key: 'reqHumanWarRecommandRice',
label: '유저전투장 권장 쌀',
step: 100,
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 유저전투장 긴급포상 쌀의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentRice') * 2)}입니다.`,
},
{
title: '전투/징병 기준',
fields: [
{
key: 'minWarCrew',
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전투장으로 분류됩니다.',
},
],
key: 'reqHumanDevelGold',
label: '유저내정장 권장 금',
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
title: '상태 기준',
fields: [
{
key: 'properWarTrainAtmos',
label: '훈련/사기진작 목표치',
step: 5,
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
},
{
key: 'cureThreshold',
label: '요양 기준(%)',
step: 5,
description: '요양 기준입니다. 이보다 많이 부상을 입으면 요양합니다.',
},
],
key: 'reqHumanDevelRice',
label: '유저내정장 권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqNPCWarGold',
label: 'NPC전투장 권장 금',
step: 100,
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100)} * 4) 징병비를 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarGold)}입니다.`,
},
{
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 = () => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
};
const rollbackPolicy = () => {
if (!lastSavedPolicy.value) {
return;
}
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(lastSavedPolicy.value);
notice.value = '이전 설정으로 되돌렸습니다.';
};
const submitPolicy = async () => {
if (!policyDraft.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
error.value = null;
notice.value = null;
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
lastSavedPolicy.value = clonePolicy(policyDraft.value);
notice.value = 'NPC 정책이 반영되었습니다.';
} 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) => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(data.value.defaultNationPriority, data.value.availableNationPriorityItems);
nationPriority.value = assignPriorityState(
data.value.defaultNationPriority,
data.value.availableNationPriorityItems
);
} else {
generalPriority.value = assignPriorityState(
data.value.defaultGeneralActionPriority,
data.value.availableGeneralActionPriorityItems
);
}
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
};
const rollbackPriority = (section: PrioritySectionKey) => {
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
if (section === 'nation' && data.value) {
nationPriority.value = assignPriorityState(lastSavedNationPriority.value, data.value.availableNationPriorityItems);
}
if (section === 'general' && data.value) {
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(
lastSavedNationPriority.value,
data.value.availableNationPriorityItems
);
} else {
generalPriority.value = assignPriorityState(
lastSavedGeneralPriority.value,
data.value.availableGeneralActionPriorityItems
);
}
notice.value = '이전 설정으로 되돌렸습니다.';
};
const submitPriority = async (section: PrioritySectionKey) => {
if (!data.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!state || !window.confirm('저장할까요?')) return;
error.value = null;
notice.value = null;
try {
if (section === 'nation' && nationPriority.value) {
await trpc.npc.setNationPriority.mutate(nationPriority.value.active);
if (section === 'nation') {
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) {
await trpc.npc.setGeneralPriority.mutate(generalPriority.value.active);
}
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
notice.value = 'NPC 정책이 반영되었습니다.';
} catch (caught) {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
}
};
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>
<template>
<main class="npc-page">
<header class="page-header">
<div>
<h1 class="page-title">NPC 정책</h1>
<p class="page-subtitle">사령/일반 AI 우선순위 자원 기준</p>
<main id="npc-policy-page" class="npc-page">
<nav class="top-back-bar legacy-bg0">
<RouterLink class="back-button" to="/">돌아가기</RouterLink>
<strong>NPC 정책</strong>
</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 class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<button class="ghost" @click="loadPolicy">새로고침</button>
<div v-if="error" class="feedback error-feedback" role="alert">{{ error }}</div>
<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>
</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">
<PanelCard title="NPC 정책 로딩">
<SkeletonLines :lines="6" />
</PanelCard>
</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 class="control_bar">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div v-for="section in policySections" :key="section.title" class="policy-section">
<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>
<button class="submit_btn" type="button" @click="submitPolicy">설정</button>
</div>
<div class="priority-grid">
<PanelCard title="NPC 사령턴 우선순위" subtitle="예턴 실패 시 우선순위대로 실행">
<div class="setter">
최근 설정: {{ data.lastSetters.nation.setter ?? '-없음-' }} ({{
data.lastSetters.nation.date ?? '설정 기록 없음'
}})
<div class="priority-sections">
<section
v-for="panel in priorityPanels"
:key="panel.key"
: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 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 class="sub_bar legacy-bg2">비활성</div>
<div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'inactive')"
>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
<div
v-for="item in nationPriority?.inactive ?? []"
v-for="(item, index) in panel.state.inactive"
:key="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>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, true)">
활성
</button>
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
</div>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div class="sub_bar legacy-bg2">활성</div>
<div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'active')"
>
<div
v-for="(item, idx) in nationPriority?.active ?? []"
:key="item"
v-for="(item, index) in panel.state.active"
:key="`${item}-${index}`"
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">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${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>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('nation')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('nation')">이전값으로</button>
<div class="control_bar priority-control">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPriority(panel.key)">
초깃값으로
</button>
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
이전값으로
</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('nation')">설정</button>
<button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
</div>
</PanelCard>
<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>
</section>
</div>
</section>
</main>
</template>
<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 {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
.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;
.legacy-bg0 {
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
.legacy-bg1 {
background-image: url('/image/game/back_green.jpg');
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.legacy-bg2 {
background-image: url('/image/game/back_blue.jpg');
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.top-back-bar {
position: relative;
height: 32px;
max-width: 1000px;
margin: 0 auto;
text-align: center;
box-sizing: border-box;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
.top-back-bar strong {
font-size: 24px;
line-height: 32px;
font-weight: 400;
}
.primary {
border: 1px solid rgba(201, 164, 90, 0.6);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(201, 164, 90, 0.2);
color: inherit;
.back-button {
position: absolute;
inset: 0 auto 0 0;
width: 88px;
color: #fff;
background: #087f45;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
font-weight: 700;
line-height: 30px;
text-decoration: none;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
.back-button:hover,
.back-button:focus-visible {
background: #0a9960;
outline: 2px solid #fff;
outline-offset: -2px;
}
.npc-layout {
display: flex;
flex-direction: column;
gap: 16px;
.policy-container,
.page-state {
width: 100%;
max-width: 1000px;
margin: 0 auto;
border: 1px solid #888;
box-sizing: border-box;
}
.setter {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
margin-bottom: 8px;
.page-state {
padding: 16px;
min-height: 100px;
}
.readonly-note {
font-size: 0.75rem;
color: rgba(245, 208, 138, 0.8);
margin-bottom: 8px;
.page-state button {
margin-left: 12px;
}
.policy-section {
margin-top: 12px;
.section_bar {
min-height: 23px;
border: 0.5px solid #aaa;
box-sizing: border-box;
text-align: center;
}
.section-title {
font-size: 0.95rem;
margin-bottom: 8px;
color: rgba(232, 221, 196, 0.9);
.setter,
.priority-meta {
min-height: 21px;
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;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 8px;
}
.policy-field {
border: 1px solid rgba(201, 164, 90, 0.2);
background: rgba(16, 16, 16, 0.5);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 0 10.5px;
box-sizing: border-box;
}
.field-label {
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 {
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
grid-template-columns: minmax(0, 1fr) 224px;
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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.priority-column {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 8px;
background: rgba(12, 12, 12, 0.5);
min-width: 0;
}
.column-title {
font-size: 0.8rem;
margin-bottom: 6px;
color: rgba(232, 221, 196, 0.7);
.sub_bar {
height: 22px;
margin: 0 5px;
border: 0.5px solid #aaa;
text-align: center;
box-sizing: border-box;
}
.priority-list {
display: flex;
min-height: 37px;
margin: 0 10px;
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 {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(16, 16, 16, 0.6);
color: #fff;
background: #303030;
cursor: grab;
}
.priority-main {
display: flex;
.priority-item:active {
cursor: grabbing;
opacity: 0.8;
}
.priority_info {
display: grid;
height: 21px;
grid-template-columns: 24px minmax(0, 1fr) 24px;
align-items: center;
gap: 6px;
}
.priority-name {
font-size: 0.78rem;
.drag-handle {
font-size: 18px;
}
.priority-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.priority-help {
.help-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 0.7rem;
border-radius: 999px;
border: 1px solid rgba(201, 164, 90, 0.4);
cursor: default;
width: 24px;
height: 22.375px;
padding: 0 3.5px;
color: #fff;
background: #444;
border: 1px solid #3d3d3d;
border-radius: 3px;
font-size: 12.25px;
line-height: 18.375px;
cursor: pointer;
}
.priority-help::after {
content: attr(data-text);
.help-button::after {
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: rgba(16, 16, 16, 0.9);
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 8px;
font-size: 0.7rem;
width: 220px;
right: 0;
bottom: calc(100% + 5px);
z-index: 10;
width: 300px;
padding: 7px;
color: #fff;
background: #111;
border: 1px solid #777;
border-radius: 4px;
content: attr(data-text);
font-size: 12px;
line-height: 18px;
text-align: left;
white-space: pre-line;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 10;
transition: opacity 0.15s ease;
}
.priority-help:hover::after {
.help-button:hover::after,
.help-button:focus-visible::after {
opacity: 1;
}
@media (max-width: 1024px) {
.npc-page {
padding: 16px;
.priority-control {
margin-top: 0;
}
@media (max-width: 991px) {
.form_list,
.priority-sections {
grid-template-columns: 1fr;
}
.half_section_left {
border-right: 0;
}
}
</style>
@@ -108,6 +108,7 @@ onMounted(() => {
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<button class="ghost" @click="refreshPublicData">새로고침</button>
</div>
</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,
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
}));
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
korName: string;
@@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
private mapProfile(
row: GatewayProfileRecord,
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
runtimeMap: Map<
string,
{ apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean }
>
): LobbyProfileStatus {
const meta = row.meta;
return {
@@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
runtime: runtimeMap.get(row.profileName) ?? {
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
korName: (meta.korName as string | undefined) ?? row.profile,
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
export interface ProfileRuntimeState {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
}
@@ -66,13 +67,19 @@ export const planProfileReconcile = (
): { shouldStart: boolean; shouldStop: boolean } => {
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
return {
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
shouldStart: !(
runtime.apiRunning &&
runtime.daemonRunning &&
runtime.battleSimRunning &&
runtime.tournamentRunning
),
shouldStop: false,
};
}
return {
shouldStart: false,
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
shouldStop:
runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning,
};
};
@@ -273,8 +280,16 @@ const parseInstallOptions = (
};
};
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string =>
`sammo:${profileName}:${
role === 'api'
? 'game-api'
: role === 'daemon'
? 'turn-daemon'
: role === 'battle-sim'
? 'battle-sim-worker'
: 'tournament-worker'
}`;
const isMissingProcessError = (error: unknown): boolean =>
error instanceof Error && /process or namespace not found/i.test(error.message);
@@ -285,11 +300,13 @@ export const buildProcessDefinitions = (
): {
api: { name: string; script: string; cwd: string; env: Record<string, string> };
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
battleSim: { name: string; script: string; cwd: string; env: Record<string, string> };
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
} => {
const baseEnv = { ...(config.baseEnv ?? {}) };
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
@@ -327,6 +344,15 @@ export const buildProcessDefinitions = (
cwd: daemonCwd,
env: daemonEnv,
},
battleSim: {
name: battleSimName,
script: apiScript,
cwd: apiCwd,
env: {
...apiEnv,
GAME_API_ROLE: 'battle-sim-worker',
},
},
tournament: {
name: tournamentName,
script: apiScript,
@@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
profileNames.map((profileName) => {
const apiName = buildProcessName(profileName, 'api');
const daemonName = buildProcessName(profileName, 'daemon');
const battleSimName = buildProcessName(profileName, 'battle-sim');
const tournamentName = buildProcessName(profileName, 'tournament');
return {
profileName,
apiRunning: processNames.get(apiName) ?? false,
daemonRunning: processNames.get(daemonName) ?? false,
battleSimRunning: processNames.get(battleSimName) ?? false,
tournamentRunning: processNames.get(tournamentName) ?? false,
};
});
@@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
try {
await this.processManager.start(definitions.api);
await this.processManager.start(definitions.daemon);
await this.processManager.start(definitions.battleSim);
await this.processManager.start(definitions.tournament);
await this.repository.updateLastError(profile.profileName, null);
return true;
@@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
const failures: string[] = [];
for (const name of [apiName, daemonName, tournamentName]) {
for (const name of [apiName, daemonName, battleSimName, tournamentName]) {
if (!existingNames.has(name)) {
continue;
}
@@ -88,6 +88,7 @@ const createHarness = (
? [
{ name: 'sammo:che:2:game-api', status: 'online' },
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
{ name: 'sammo:che:2:battle-sim-worker', status: 'online' },
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
]
: [],
@@ -138,7 +139,7 @@ const createHarness = (
};
describe('GatewayOrchestrator first-class operations', () => {
it('starts both profile processes and records success', async () => {
it('starts every profile process and records success', async () => {
const harness = createHarness(buildOperation('START'));
await harness.orchestrator.runOperationsNow();
@@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.started.map((definition) => definition.name)).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('stops both profile processes and records success', async () => {
it('stops every profile process and records success', async () => {
const harness = createHarness(buildOperation('STOP'));
await harness.orchestrator.runOperationsNow();
@@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.stopped).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
@@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
@@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.completions).toEqual(['FAILED']);
});
it('attempts to stop both roles before reporting a partial PM2 failure', async () => {
it('attempts to stop every role before reporting a partial PM2 failure', async () => {
const harness = createHarness(buildOperation('STOP'), false, true);
await harness.orchestrator.runOperationsNow();
@@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.stopped).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.completions).toEqual(['FAILED']);
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RUNNING', {
apiRunning: true,
daemonRunning: false,
battleSimRunning: true,
tournamentRunning: true,
})
).toEqual({ shouldStart: true, shouldStop: false });
@@ -39,6 +40,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('PREOPEN', {
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
})
).toEqual({ shouldStart: true, shouldStop: false });
@@ -49,6 +51,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RUNNING', {
apiRunning: true,
daemonRunning: true,
battleSimRunning: true,
tournamentRunning: true,
})
).toEqual({ shouldStart: false, shouldStop: false });
@@ -59,6 +62,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('STOPPED', {
apiRunning: false,
daemonRunning: true,
battleSimRunning: false,
tournamentRunning: false,
})
).toEqual({ shouldStart: false, shouldStop: true });
@@ -69,6 +73,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RESERVED', {
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
})
).toEqual({ shouldStart: false, shouldStop: false });
@@ -95,6 +100,11 @@ describe('buildProcessDefinitions', () => {
});
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
expect(definitions.battleSim).toMatchObject({
cwd: path.join(buildWorkspace, 'app', 'game-api'),
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
env: { GAME_API_ROLE: 'battle-sim-worker' },
});
expect(definitions.tournament).toMatchObject({
cwd: path.join(buildWorkspace, 'app', 'game-api'),
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
@@ -107,6 +117,7 @@ describe('buildProcessDefinitions', () => {
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
});
});
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
profileName: 'che:2',
apiRunning: runtimeRunning,
daemonRunning: runtimeRunning,
battleSimRunning: runtimeRunning,
tournamentRunning: runtimeRunning,
},
});
+3 -1
View File
@@ -70,6 +70,7 @@ type AdminProfile = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
buildCommitSha?: string;
@@ -1352,7 +1353,8 @@ onMounted(() => {
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM:
{{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
</div>
</div>
@@ -16,7 +16,12 @@ type Profile = {
buildWorkspace?: string;
buildError?: string;
lastError?: string;
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
};
type Scenario = {
@@ -377,6 +382,14 @@ onBeforeUnmount(() => {
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Battle sim worker</div>
<div
:class="selectedProfile.runtime.battleSimRunning ? 'text-emerald-400' : 'text-zinc-500'"
>
{{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Tournament worker</div>
<div