Merge branch 'main' into feature/nation-general-lists
# Conflicts: # app/game-frontend/package.json # app/game-frontend/src/router/index.ts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
|
||||
|
||||
export interface BattleSimJob {
|
||||
jobId: string;
|
||||
requesterUserId: string;
|
||||
requestedAt: string;
|
||||
payload: BattleSimJobPayload;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface GameApiConfig {
|
||||
export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GameApiConfig => {
|
||||
const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'hwe';
|
||||
const scenario = env.SCENARIO ?? 'default';
|
||||
const profileName = `${profile}:${scenario}`;
|
||||
const profileName = env.GAME_PROFILE_NAME ?? `${profile}:${scenario}`;
|
||||
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
|
||||
if (!secret) {
|
||||
throw new Error('GAME_TOKEN_SECRET is required for game token verification.');
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -594,7 +594,7 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? true,
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||
});
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
|
||||
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
|
||||
|
||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: nationId },
|
||||
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
|
||||
return resolveNationPermission(general, nation.meta, true);
|
||||
};
|
||||
|
||||
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
|
||||
const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
|
||||
if (state === 'ACTIVATED') return 'ACTIVATED';
|
||||
if (state === 'CANCELLED') return 'CANCELLED';
|
||||
if (state === 'REPLACED') return 'REPLACED';
|
||||
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
|
||||
select: { id: true },
|
||||
});
|
||||
if (newer) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (prevLetter.state === 'PROPOSED') {
|
||||
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
destNationId =
|
||||
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
}
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
@@ -372,4 +374,4 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
return { state: 'ACTIVATED' };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,22 +2,62 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { router, procedure } from '../../trpc.js';
|
||||
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
|
||||
const zDynastyDetailInput = z.object({
|
||||
emperorId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const parseNumberArray = (value: unknown): number[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : [];
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
: [];
|
||||
|
||||
const parseTextArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
const parseDisplayArray = (value: unknown): Array<string | number> =>
|
||||
Array.isArray(value)
|
||||
? value.filter(
|
||||
(item): item is string | number =>
|
||||
typeof item === 'string' || (typeof item === 'number' && Number.isFinite(item))
|
||||
)
|
||||
: [];
|
||||
|
||||
const formatNationType = (typeCode: string): string => {
|
||||
const separator = typeCode.indexOf('_');
|
||||
return separator < 0 ? typeCode : typeCode.slice(separator + 1);
|
||||
};
|
||||
|
||||
const formatNationLevel = (level: number | null): string => {
|
||||
if (level === null) {
|
||||
return '';
|
||||
}
|
||||
return ['방랑군', '호족', '군벌', '주자사', '주목', '공', '왕', '황제'][level] ?? String(level);
|
||||
};
|
||||
|
||||
export const dynastyRouter = router({
|
||||
getList: procedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.emperor.findMany({
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
const [worldState, rows] = await Promise.all([
|
||||
ctx.db.worldState.findFirst({
|
||||
select: {
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.emperor.findMany({
|
||||
orderBy: { id: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
current: worldState
|
||||
? {
|
||||
year: worldState.currentYear,
|
||||
month: worldState.currentMonth,
|
||||
}
|
||||
: null,
|
||||
entries: rows.map((row) => ({
|
||||
id: row.id,
|
||||
serverId: row.serverId ?? '',
|
||||
@@ -30,11 +70,19 @@ export const dynastyRouter = router({
|
||||
power: row.power ?? 0,
|
||||
gennum: row.gennum ?? 0,
|
||||
citynum: row.citynum ?? 0,
|
||||
l12name: row.l12name ?? '',
|
||||
l11name: row.l11name ?? '',
|
||||
l10name: row.l10name ?? '',
|
||||
l9name: row.l9name ?? '',
|
||||
l8name: row.l8name ?? '',
|
||||
l7name: row.l7name ?? '',
|
||||
l6name: row.l6name ?? '',
|
||||
l5name: row.l5name ?? '',
|
||||
})),
|
||||
};
|
||||
}),
|
||||
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
|
||||
const emperor = await ctx.db.emperor.findFirst({
|
||||
const emperor = await ctx.db.emperor.findUnique({
|
||||
where: { id: input.emperorId },
|
||||
});
|
||||
if (!emperor) {
|
||||
@@ -43,7 +91,6 @@ export const dynastyRouter = router({
|
||||
|
||||
const aux = asRecord(emperor.aux);
|
||||
const winnerNationId = typeof aux.winnerNationId === 'number' ? aux.winnerNationId : null;
|
||||
|
||||
const serverId = emperor.serverId ?? '';
|
||||
const oldNationRows = await ctx.db.oldNation.findMany({
|
||||
where: { serverId },
|
||||
@@ -54,19 +101,26 @@ export const dynastyRouter = router({
|
||||
.map((row) => {
|
||||
const data = asRecord(row.data);
|
||||
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
|
||||
const typeCode = typeof data.type === 'string' ? data.type : '';
|
||||
return {
|
||||
nation: nationId,
|
||||
isWinner: winnerNationId !== null && nationId === winnerNationId,
|
||||
name: typeof data.name === 'string' ? data.name : row.nation === 0 ? '재야' : '미상',
|
||||
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
|
||||
color: typeof data.color === 'string' ? data.color : '#000000',
|
||||
type: typeof data.type === 'string' ? data.type : '',
|
||||
type: typeCode,
|
||||
typeName: formatNationType(typeCode),
|
||||
level: typeof data.level === 'number' ? data.level : null,
|
||||
tech: typeof data.tech === 'number' ? data.tech : null,
|
||||
maxPower: typeof data.maxPower === 'number' ? data.maxPower : null,
|
||||
maxPower:
|
||||
typeof data.maxPower === 'number'
|
||||
? data.maxPower
|
||||
: typeof data.power === 'number'
|
||||
? data.power
|
||||
: null,
|
||||
maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null,
|
||||
maxCities: Array.isArray(data.maxCities) ? data.maxCities : [],
|
||||
maxCities: parseDisplayArray(data.maxCities),
|
||||
generals: parseNumberArray(data.generals),
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
history: parseTextArray(data.history),
|
||||
date: row.date.toISOString(),
|
||||
};
|
||||
})
|
||||
@@ -89,6 +143,7 @@ export const dynastyRouter = router({
|
||||
|
||||
const nations = nationEntries.map((entry) => ({
|
||||
...entry,
|
||||
levelName: formatNationLevel(entry.level),
|
||||
generalsFull: entry.generals.map((id) => ({
|
||||
generalNo: id,
|
||||
name: generalMap.get(id)?.name ?? `#${id}`,
|
||||
@@ -131,7 +186,7 @@ export const dynastyRouter = router({
|
||||
tiger: emperor.tiger ?? '',
|
||||
eagle: emperor.eagle ?? '',
|
||||
gen: emperor.gen ?? '',
|
||||
history: Array.isArray(emperor.history) ? emperor.history : [],
|
||||
history: parseTextArray(emperor.history),
|
||||
},
|
||||
nations,
|
||||
};
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
|
||||
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
type WorldTrendSnapshot = {
|
||||
year: number;
|
||||
@@ -42,6 +43,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 +172,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;
|
||||
@@ -203,6 +232,11 @@ const sortNpcList = <T extends {
|
||||
});
|
||||
|
||||
export const publicRouter = router({
|
||||
recordAccess: sessionActivityProcedure
|
||||
.input(z.object({ page: z.enum(accessPages) }))
|
||||
.mutation(async ({ ctx, input }) => ({
|
||||
recorded: await recordGeneralAccess(ctx, input.page),
|
||||
})),
|
||||
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||
return loadMapLayout(ctx.profile.scenario);
|
||||
}),
|
||||
@@ -222,6 +256,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({
|
||||
|
||||
@@ -2,11 +2,35 @@ 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_BG_COLOR = '#330000';
|
||||
const DEFAULT_FG_COLOR = '#ffffff';
|
||||
const NEUTRAL_BG_COLOR = '#000000';
|
||||
const LEGACY_WHITE_TEXT_COLORS = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#6495ED',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#800080',
|
||||
'#A9A9A9',
|
||||
'#000000',
|
||||
]);
|
||||
|
||||
const readMetaNumber = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -21,33 +45,43 @@ const readMetaNumber = (value: unknown): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
|
||||
export const formatLegacyRankingNumber = (value: number, fractionDigits = 0): string =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
useGrouping: true,
|
||||
}).format(value);
|
||||
|
||||
export const resolveLegacyTextColor = (backgroundColor: string): string =>
|
||||
LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000';
|
||||
|
||||
const percentText = (value: number): string => `${formatLegacyRankingNumber(value * 100, 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 +91,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;
|
||||
@@ -69,6 +103,7 @@ export const rankingRouter = router({
|
||||
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: npcFilter },
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -76,6 +111,7 @@ export const rankingRouter = router({
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
meta: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
horseCode: true,
|
||||
@@ -129,11 +165,11 @@ export const rankingRouter = router({
|
||||
}
|
||||
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
||||
}],
|
||||
['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0],
|
||||
['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0],
|
||||
['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0],
|
||||
['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0],
|
||||
['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0],
|
||||
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
|
||||
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
|
||||
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
|
||||
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
|
||||
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
|
||||
['전 력 전 승 률', 'percent', (_g, r) => {
|
||||
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
|
||||
if (total < 50) {
|
||||
@@ -182,17 +218,20 @@ export const rankingRouter = router({
|
||||
const ranks = rankMap.get(general.id) ?? {};
|
||||
const value = valueFn(general, ranks);
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
const bgColor =
|
||||
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
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,
|
||||
bgColor,
|
||||
fgColor: resolveLegacyTextColor(bgColor),
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
value,
|
||||
printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'),
|
||||
printValue:
|
||||
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
|
||||
};
|
||||
|
||||
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
|
||||
@@ -202,7 +241,7 @@ export const rankingRouter = router({
|
||||
ownerName: null,
|
||||
nationName: '???',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
};
|
||||
@@ -217,46 +256,93 @@ 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;
|
||||
const bgColor =
|
||||
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor,
|
||||
fgColor: resolveLegacyTextColor(bgColor),
|
||||
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: resolveLegacyTextColor(DEFAULT_BG_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,13 +425,20 @@ 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),
|
||||
picture: typeof aux.picture === 'string' ? aux.picture : null,
|
||||
imageServer: readMetaNumber(aux.imgsvr),
|
||||
value: row.value,
|
||||
printValue: type.type === 'percent' ? percentText(row.value) : Math.floor(row.value).toLocaleString('ko-KR'),
|
||||
printValue:
|
||||
type.type === 'percent'
|
||||
? percentText(row.value)
|
||||
: formatLegacyRankingNumber(row.value),
|
||||
serverName: String(aux.serverName ?? ''),
|
||||
serverIdx: readMetaNumber(aux.serverIdx),
|
||||
scenarioName: String(aux.scenarioName ?? ''),
|
||||
|
||||
@@ -7,7 +7,8 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
type YearbookNation = {
|
||||
id: number;
|
||||
@@ -19,6 +20,7 @@ type YearbookNation = {
|
||||
};
|
||||
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
const zServerId = z.string().trim().min(1).max(64);
|
||||
|
||||
const computeHash = (payload: unknown): string =>
|
||||
createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
@@ -189,53 +191,86 @@ const buildLogs = async (ctx: GameApiContext, year: number, month: number) => {
|
||||
};
|
||||
|
||||
export const yearbookRouter = router({
|
||||
getRange: 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.' });
|
||||
}
|
||||
getRange: authedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
serverID: zServerId.optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
const targetProfileName = input?.serverID ?? ctx.profile.name;
|
||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
||||
|
||||
const firstRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: ctx.profile.name },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'asc' }, { month: 'asc' }],
|
||||
});
|
||||
const firstRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: targetProfileName },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'asc' }, { month: 'asc' }],
|
||||
});
|
||||
|
||||
const lastRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: ctx.profile.name },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'desc' }, { month: 'desc' }],
|
||||
});
|
||||
const lastRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: targetProfileName },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'desc' }, { month: 'desc' }],
|
||||
});
|
||||
|
||||
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
|
||||
const fallbackYearMonth = currentYearMonth - 1;
|
||||
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
||||
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
||||
if (!isCurrentProfile && (!firstRow || !lastRow)) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
firstYearMonth,
|
||||
lastYearMonth,
|
||||
currentYearMonth,
|
||||
};
|
||||
}),
|
||||
getHistory: procedure
|
||||
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
|
||||
const fallbackYearMonth = currentYearMonth - 1;
|
||||
const firstYearMonth = firstRow
|
||||
? joinYearMonth(firstRow.year, firstRow.month)
|
||||
: fallbackYearMonth;
|
||||
const lastYearMonth = lastRow
|
||||
? joinYearMonth(lastRow.year, lastRow.month)
|
||||
: fallbackYearMonth;
|
||||
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
|
||||
|
||||
return {
|
||||
firstYearMonth,
|
||||
lastYearMonth,
|
||||
currentYearMonth: selectedYearMonth,
|
||||
};
|
||||
}),
|
||||
getHistory: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
year: z.number().int(),
|
||||
month: z.number().int().min(1).max(12),
|
||||
hash: z.string().optional(),
|
||||
serverID: zServerId.optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||
}
|
||||
const targetProfileName = input.serverID ?? ctx.profile.name;
|
||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
||||
|
||||
const isCurrent =
|
||||
isCurrentProfile &&
|
||||
worldState.currentYear === input.year && worldState.currentMonth === input.month;
|
||||
|
||||
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
|
||||
const { globalHistory, globalAction } = isCurrentProfile
|
||||
? await buildLogs(ctx, input.year, input.month)
|
||||
: {
|
||||
globalHistory: [`<C>●</>${input.month}월: 기록 없음`],
|
||||
globalAction: [`<C>●</>${input.month}월: 기록 없음`],
|
||||
};
|
||||
|
||||
if (isCurrent) {
|
||||
const map = await loadPublicMap(ctx, false);
|
||||
@@ -260,7 +295,7 @@ export const yearbookRouter = router({
|
||||
|
||||
const row = await ctx.db.yearbookHistory.findFirst({
|
||||
where: {
|
||||
profileName: ctx.profile.name,
|
||||
profileName: targetProfileName,
|
||||
year: input.year,
|
||||
month: input.month,
|
||||
},
|
||||
@@ -285,4 +320,4 @@ export const yearbookRouter = router({
|
||||
}
|
||||
return { notModified: false, hash, data };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
|
||||
export const accessPages = [
|
||||
'front-info',
|
||||
'nation-info',
|
||||
'nation-cities',
|
||||
'global-info',
|
||||
'nation-list',
|
||||
'general-list',
|
||||
'current-city',
|
||||
'diplomacy',
|
||||
'nation-generals',
|
||||
'nation-personnel',
|
||||
'nation-finance',
|
||||
'battle-center',
|
||||
'board',
|
||||
'best-general',
|
||||
'hall-of-fame',
|
||||
'dynasty',
|
||||
'yearbook',
|
||||
'nation-betting',
|
||||
'traffic',
|
||||
'npc-list',
|
||||
'my-page',
|
||||
'npc-control',
|
||||
'tournament',
|
||||
'betting',
|
||||
] as const;
|
||||
|
||||
export type AccessPage = (typeof accessPages)[number];
|
||||
|
||||
export const accessPageWeights: Record<AccessPage, number> = {
|
||||
'front-info': 1,
|
||||
'nation-info': 1,
|
||||
'nation-cities': 1,
|
||||
'global-info': 1,
|
||||
'nation-list': 2,
|
||||
'general-list': 2,
|
||||
'current-city': 1,
|
||||
diplomacy: 1,
|
||||
'nation-generals': 1,
|
||||
'nation-personnel': 1,
|
||||
'nation-finance': 1,
|
||||
'battle-center': 1,
|
||||
board: 1,
|
||||
'best-general': 1,
|
||||
'hall-of-fame': 1,
|
||||
dynasty: 1,
|
||||
yearbook: 1,
|
||||
'nation-betting': 1,
|
||||
traffic: 1,
|
||||
'npc-list': 2,
|
||||
'my-page': 1,
|
||||
'npc-control': 1,
|
||||
tournament: 1,
|
||||
betting: 1,
|
||||
};
|
||||
|
||||
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||
|
||||
const readFiniteNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readDate = (value: unknown): Date | null => {
|
||||
if (typeof value !== 'string' && !(value instanceof Date)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
export const resolveAccessWindows = (
|
||||
now: Date,
|
||||
tickSeconds: number,
|
||||
worldMeta: unknown
|
||||
): { dayStartedAt: Date; scoreStartedAt: Date } => {
|
||||
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const meta = asRecord(worldMeta);
|
||||
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
|
||||
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
|
||||
const scoreStartedAt =
|
||||
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
|
||||
? tickStartedAt
|
||||
: new Date(now.getTime() - fallbackTickMs);
|
||||
return { dayStartedAt, scoreStartedAt };
|
||||
};
|
||||
|
||||
export const upsertGeneralAccess = async (
|
||||
db: Pick<GameApiContext['db'], '$executeRaw'>,
|
||||
input: {
|
||||
generalId: number;
|
||||
userId: string;
|
||||
weight: number;
|
||||
now: Date;
|
||||
dayStartedAt: Date;
|
||||
scoreStartedAt: Date;
|
||||
}
|
||||
): Promise<void> => {
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO general_access_log (
|
||||
general_id,
|
||||
user_id,
|
||||
last_refresh,
|
||||
refresh,
|
||||
refresh_total,
|
||||
refresh_score,
|
||||
refresh_score_total
|
||||
)
|
||||
VALUES (
|
||||
${input.generalId},
|
||||
${input.userId},
|
||||
${input.now},
|
||||
${input.weight},
|
||||
${input.weight},
|
||||
${input.weight},
|
||||
${input.weight}
|
||||
)
|
||||
ON CONFLICT (general_id) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
last_refresh = EXCLUDED.last_refresh,
|
||||
refresh = CASE
|
||||
WHEN general_access_log.last_refresh IS NULL
|
||||
OR general_access_log.last_refresh < ${input.dayStartedAt}
|
||||
THEN EXCLUDED.refresh
|
||||
ELSE general_access_log.refresh + EXCLUDED.refresh
|
||||
END,
|
||||
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
|
||||
refresh_score = CASE
|
||||
WHEN general_access_log.last_refresh IS NULL
|
||||
OR general_access_log.last_refresh < ${input.scoreStartedAt}
|
||||
THEN EXCLUDED.refresh_score
|
||||
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
|
||||
END,
|
||||
refresh_score_total =
|
||||
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
export const recordGeneralAccess = async (
|
||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||
page: AccessPage,
|
||||
now = new Date()
|
||||
): Promise<boolean> => {
|
||||
const user = ctx.auth?.user;
|
||||
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [general, worldState] = await Promise.all([
|
||||
ctx.db.general.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { tickSeconds: true, meta: true },
|
||||
}),
|
||||
]);
|
||||
if (!general || !worldState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0;
|
||||
const openTime = readDate(meta.opentime);
|
||||
if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const weight = accessPageWeights[page];
|
||||
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||
|
||||
await upsertGeneralAccess(ctx.db, {
|
||||
generalId: general.id,
|
||||
userId: user.id,
|
||||
weight,
|
||||
now,
|
||||
dayStartedAt,
|
||||
scoreStartedAt,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
+24
-14
@@ -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,12 @@ 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);
|
||||
|
||||
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
||||
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
||||
export const sessionActivityProcedure = t.procedure;
|
||||
|
||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
|
||||
@@ -203,6 +203,9 @@ 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),
|
||||
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01),
|
||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { GamePrisma, 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: 50,
|
||||
strength: 50,
|
||||
intel: 50,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
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: 'None',
|
||||
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 sqlText = (query: GamePrisma.Sql): string => query.strings.join(' ');
|
||||
|
||||
const buildContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
general?: GeneralRow | null;
|
||||
auctions?: Array<Record<string, unknown>>;
|
||||
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
const requestCommand = vi.fn(async (command: { type: string }) => {
|
||||
if (command.type === 'auctionOpen') {
|
||||
return {
|
||||
type: 'auctionOpen' as const,
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'auctionBid' as const,
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
};
|
||||
});
|
||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||
const worldState = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
},
|
||||
},
|
||||
meta: { hiddenSeed: 'auction-hidden-seed' },
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
};
|
||||
const db = {
|
||||
$queryRaw: queryRaw,
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
general?.userId === where.userId ? general : null
|
||||
),
|
||||
findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) =>
|
||||
where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' }))
|
||||
),
|
||||
},
|
||||
auction: {
|
||||
findMany: vi.fn(async () => options.auctions ?? []),
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => worldState),
|
||||
},
|
||||
inheritancePoint: {
|
||||
findUnique: vi.fn(async () => ({ value: 10_000 })),
|
||||
},
|
||||
logEntry: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
zAdd: vi.fn(async () => 1),
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: redis as unknown 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, db, queryRaw, redis, requestCommand };
|
||||
};
|
||||
|
||||
describe('auction router actor and permission boundaries', () => {
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects reads and mutations when the authenticated user owns no general', async () => {
|
||||
const fixture = buildContext({
|
||||
auth: buildAuth('user-2'),
|
||||
general: buildGeneral({ userId: 'user-1' }),
|
||||
});
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.auction.getOverview()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'General not found.',
|
||||
});
|
||||
await expect(
|
||||
caller.auction.openBuyRice({
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'General not found.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => {
|
||||
const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) });
|
||||
const input = {
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
generalId: 999,
|
||||
};
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.openBuyRice(input);
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionOpen',
|
||||
auctionType: 'BUY_RICE',
|
||||
generalId: 7,
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts real unique-auction identities while preserving caller markers', async () => {
|
||||
const openedAt = new Date('2026-07-26T01:00:00Z');
|
||||
const fixture = buildContext({
|
||||
auctions: [
|
||||
{
|
||||
id: 31,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
hostGeneralId: 7,
|
||||
hostName: null,
|
||||
detail: { title: '칠성검 경매', startBidAmount: 5000 },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||
bids: [
|
||||
{
|
||||
id: 41,
|
||||
generalId: 88,
|
||||
amount: 5500,
|
||||
eventAt: openedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).auction.getOverview();
|
||||
const unique = result.uniqueAuctions[0];
|
||||
|
||||
expect(unique).toMatchObject({
|
||||
id: 31,
|
||||
hostGeneralId: null,
|
||||
isCallerHost: true,
|
||||
highestBid: { amount: 5500, isCaller: false },
|
||||
});
|
||||
expect(unique?.hostName).not.toBe('유비');
|
||||
expect(unique?.highestBid?.bidderName).not.toBe('관우');
|
||||
expect(JSON.stringify(unique)).not.toContain('"generalId"');
|
||||
expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7');
|
||||
});
|
||||
|
||||
it('keeps the legacy default of no requested close extension for a unique bid', async () => {
|
||||
const fixture = buildContext({
|
||||
queryRaw: async (query) => {
|
||||
const text = sqlText(query);
|
||||
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
hostGeneralId: 88,
|
||||
detail: { startBidAmount: 100, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('FROM auction_bid') && text.includes('general_id =')) {
|
||||
return [];
|
||||
}
|
||||
if (text.includes('SELECT bid.auction_id')) {
|
||||
return [{ auctionId: 31, generalId: 88, amount: 100 }];
|
||||
}
|
||||
if (text.includes('FROM auction_bid')) {
|
||||
return [{ id: 41, generalId: 88, amount: 100, meta: {} }];
|
||||
}
|
||||
if (text.includes('SELECT id, target_code')) {
|
||||
return [{ id: 31, targetCode: 'che_무기_12_칠성검' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.bidUnique({
|
||||
auctionId: 31,
|
||||
amount: 110,
|
||||
});
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionBid',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from '../src/config.js';
|
||||
|
||||
describe('resolveGameApiConfigFromEnv', () => {
|
||||
it('keeps the deployment profile identity separate from the scenario id', () => {
|
||||
const config = resolveGameApiConfigFromEnv({
|
||||
PROFILE: 'hwe',
|
||||
SCENARIO: '1010',
|
||||
GAME_PROFILE_NAME: 'hwe:2',
|
||||
GAME_TOKEN_SECRET: 'test-secret',
|
||||
});
|
||||
|
||||
expect(config.profile).toBe('hwe');
|
||||
expect(config.scenario).toBe('1010');
|
||||
expect(config.profileName).toBe('hwe:2');
|
||||
});
|
||||
|
||||
it('falls back to the legacy profile and scenario pair', () => {
|
||||
const config = resolveGameApiConfigFromEnv({
|
||||
PROFILE: 'hwe',
|
||||
SCENARIO: '2',
|
||||
GAME_TOKEN_SECRET: 'test-secret',
|
||||
});
|
||||
|
||||
expect(config.profileName).toBe('hwe:2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
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 emperor = {
|
||||
id: 7,
|
||||
serverId: 'hwe_260725_fixture',
|
||||
phase: '훼2기',
|
||||
nationCount: '3 / 8',
|
||||
nationName: '촉, 위, 오',
|
||||
nationHist: '병가(2), 유가(1)',
|
||||
genCount: '7 / 21',
|
||||
personalHist: '의리(4)',
|
||||
specialHist: '상재(2)',
|
||||
name: '촉',
|
||||
type: 'che_병가',
|
||||
color: '#FF0000',
|
||||
year: 215,
|
||||
month: 4,
|
||||
power: 34434,
|
||||
gennum: 7,
|
||||
citynum: 8,
|
||||
pop: '12345 / 15000',
|
||||
poprate: '82.3 %',
|
||||
gold: 50000,
|
||||
rice: 60000,
|
||||
l12name: '유비',
|
||||
l12pic: '',
|
||||
l11name: '제갈량',
|
||||
l11pic: '',
|
||||
l10name: '관우',
|
||||
l10pic: '',
|
||||
l9name: '방통',
|
||||
l9pic: '',
|
||||
l8name: '장비',
|
||||
l8pic: '',
|
||||
l7name: '법정',
|
||||
l7pic: '',
|
||||
l6name: '조운',
|
||||
l6pic: '',
|
||||
l5name: '마량',
|
||||
l5pic: '',
|
||||
tiger: '관우【10】',
|
||||
eagle: '방통【7】',
|
||||
gen: '유비, 제갈량',
|
||||
history: ['<C>●</>촉이 천하를 통일'],
|
||||
aux: { winnerNationId: 1, privateNote: 'not-returned' },
|
||||
};
|
||||
|
||||
const oldNation = {
|
||||
id: 3,
|
||||
serverId: emperor.serverId,
|
||||
nation: 1,
|
||||
data: {
|
||||
nation: 1,
|
||||
name: '촉',
|
||||
color: '#FF0000',
|
||||
type: 'che_병가',
|
||||
level: 7,
|
||||
tech: 4000,
|
||||
power: 34434,
|
||||
maxCrew: 120000,
|
||||
maxCities: ['성도', '한중'],
|
||||
generals: [11, 12],
|
||||
history: ['<Y>유비</>가 황제로 즉위'],
|
||||
owner: 'not-returned',
|
||||
},
|
||||
date: new Date('2026-07-25T12:00:00.000Z'),
|
||||
};
|
||||
|
||||
const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: '2026-07-25T00:00:00.000Z',
|
||||
expiresAt: '2026-07-26T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => {
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||
},
|
||||
emperor: {
|
||||
findMany: async () => [emperor],
|
||||
findUnique: async ({ where }: { where: { id: number } }) => (where.id === emperor.id ? emperor : null),
|
||||
},
|
||||
oldNation: {
|
||||
findMany: async ({ where }: { where: { serverId: string } }) =>
|
||||
where.serverId === emperor.serverId ? [oldNation] : [],
|
||||
},
|
||||
oldGeneral: {
|
||||
findMany: async () => [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
},
|
||||
};
|
||||
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,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('dynasty public read model', () => {
|
||||
it('returns the current row and the complete legacy officer summary', async () => {
|
||||
const result = await appRouter.createCaller(buildContext(null)).dynasty.getList();
|
||||
|
||||
expect(result.current).toEqual({ year: 220, month: 1 });
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
serverId: 'hwe_260725_fixture',
|
||||
phase: '훼2기',
|
||||
name: '촉',
|
||||
l12name: '유비',
|
||||
l11name: '제갈량',
|
||||
l10name: '관우',
|
||||
l9name: '방통',
|
||||
l8name: '장비',
|
||||
l7name: '법정',
|
||||
l6name: '조운',
|
||||
l5name: '마량',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the same public DTO to anonymous, general owners and admins', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const otherOwner = appRouter.createCaller(buildContext(authFor('owner-b')));
|
||||
const admin = appRouter.createCaller(buildContext(authFor('admin', ['admin'])));
|
||||
|
||||
const [anonymousList, ownerList, otherOwnerList, adminList] = await Promise.all([
|
||||
anonymous.dynasty.getList(),
|
||||
owner.dynasty.getList(),
|
||||
otherOwner.dynasty.getList(),
|
||||
admin.dynasty.getList(),
|
||||
]);
|
||||
expect(ownerList).toEqual(anonymousList);
|
||||
expect(otherOwnerList).toEqual(anonymousList);
|
||||
expect(adminList).toEqual(anonymousList);
|
||||
|
||||
const [anonymousDetail, ownerDetail, otherOwnerDetail, adminDetail] = await Promise.all([
|
||||
anonymous.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
owner.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
otherOwner.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
admin.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
]);
|
||||
expect(ownerDetail).toEqual(anonymousDetail);
|
||||
expect(otherOwnerDetail).toEqual(anonymousDetail);
|
||||
expect(adminDetail).toEqual(anonymousDetail);
|
||||
});
|
||||
|
||||
it('returns only the legacy public archive fields and resolves general names', async () => {
|
||||
const result = await appRouter.createCaller(buildContext(authFor('owner-a'))).dynasty.getDetail({
|
||||
emperorId: emperor.id,
|
||||
});
|
||||
|
||||
expect(result.emperor).toEqual(
|
||||
expect.objectContaining({
|
||||
personalHist: '의리(4)',
|
||||
specialHist: '상재(2)',
|
||||
tiger: '관우【10】',
|
||||
eagle: '방통【7】',
|
||||
history: ['<C>●</>촉이 천하를 통일'],
|
||||
})
|
||||
);
|
||||
expect(result.nations).toEqual([
|
||||
expect.objectContaining({
|
||||
name: '촉',
|
||||
type: 'che_병가',
|
||||
typeName: '병가',
|
||||
levelName: '황제',
|
||||
maxPower: 34434,
|
||||
generalsFull: [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('privateNote');
|
||||
expect(JSON.stringify(result)).not.toContain('not-returned');
|
||||
expect(JSON.stringify(result)).not.toContain('"owner"');
|
||||
expect(JSON.stringify(result)).not.toContain('"data"');
|
||||
});
|
||||
|
||||
it('rejects invalid and missing record identifiers without querying another scope', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(null));
|
||||
|
||||
await expect(caller.dynasty.getDetail({ emperorId: 0 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(caller.dynasty.getDetail({ emperorId: 999 })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 9_980_071;
|
||||
|
||||
integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('atomically increments concurrent requests and resets only windowed counters', async () => {
|
||||
const firstWindow = {
|
||||
generalId,
|
||||
userId: 'access-user-a',
|
||||
now: new Date('2026-07-26T03:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
};
|
||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, index) =>
|
||||
upsertGeneralAccess(db, {
|
||||
...firstWindow,
|
||||
now: new Date(firstWindow.now.getTime() + index + 1),
|
||||
weight: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-a',
|
||||
refresh: 22,
|
||||
refreshTotal: 22,
|
||||
refreshScore: 22,
|
||||
refreshScoreTotal: 22,
|
||||
});
|
||||
|
||||
await upsertGeneralAccess(db, {
|
||||
generalId,
|
||||
userId: 'access-user-b',
|
||||
now: new Date('2026-07-27T00:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-b',
|
||||
refresh: 1,
|
||||
refreshTotal: 23,
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 23,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
|
||||
import { accessPageWeights, recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
|
||||
|
||||
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'access-session',
|
||||
user: {
|
||||
id: 'user-7',
|
||||
username: 'user7',
|
||||
displayName: '사용자7',
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||
const findWorld = vi.fn(async () => ({
|
||||
tickSeconds: 600,
|
||||
meta: {
|
||||
opentime: '2026-07-25T00:00:00.000Z',
|
||||
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
||||
...meta,
|
||||
},
|
||||
}));
|
||||
const db = {
|
||||
$executeRaw: executeRaw,
|
||||
general: { findFirst: findGeneral },
|
||||
worldState: { findFirst: findWorld },
|
||||
} as unknown as DatabaseClient;
|
||||
return { db, executeRaw, findGeneral, findWorld };
|
||||
};
|
||||
|
||||
describe('general access tracking', () => {
|
||||
it('uses the legacy weight two for both global directory pages', () => {
|
||||
expect(accessPageWeights['nation-list']).toBe(2);
|
||||
expect(accessPageWeights['general-list']).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves the UTC day and latest processed turn windows', () => {
|
||||
expect(
|
||||
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
||||
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
||||
const { db, executeRaw, findGeneral } = buildDb();
|
||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||
expect(findGeneral).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-7' },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(statement.values).toEqual([
|
||||
7,
|
||||
'user-7',
|
||||
now,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
new Date('2026-07-26T00:00:00.000Z'),
|
||||
new Date('2026-07-26T03:00:00.000Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||
const anonymous = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const admin = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(admin.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
||||
await expect(
|
||||
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||
).resolves.toBe(false);
|
||||
expect(future.executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
const united = buildDb({ isUnited: 2 });
|
||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(united.executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
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';
|
||||
import { formatLegacyRankingNumber, resolveLegacyTextColor } from '../src/router/ranking/index.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: '공개소유자', dex1: 120 },
|
||||
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: '빙의소유자', dex1: 80 },
|
||||
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: { dex1: 200 },
|
||||
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 },
|
||||
{ generalId: 1, type: 'dex1', value: 999 },
|
||||
{ generalId: 2, type: 'dex1', value: 999 },
|
||||
{ generalId: 3, type: 'dex1', value: 999 },
|
||||
],
|
||||
},
|
||||
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]);
|
||||
});
|
||||
|
||||
it('uses the general dex columns as the legacy source of truth instead of mirrored rank rows', async () => {
|
||||
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'user' });
|
||||
const dex = result.sections.find((section) => section.title === '보 병 숙 련 도');
|
||||
|
||||
expect(dex?.entries.map((entry) => [entry.id, entry.value, entry.printValue])).toEqual([
|
||||
[1, 120, '120'],
|
||||
[2, 80, '80'],
|
||||
]);
|
||||
expect(dex?.entries[0]).toMatchObject({
|
||||
bgColor: '#006400',
|
||||
fgColor: '#000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches PHP number_format rounding and the legacy fixed color table', () => {
|
||||
expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01');
|
||||
expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60');
|
||||
expect(resolveLegacyTextColor('#006400')).toBe('#000000');
|
||||
expect(resolveLegacyTextColor('#330000')).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
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 archiveServerId = 'hwe_260725_archive';
|
||||
const archiveRows = [
|
||||
{
|
||||
id: 1,
|
||||
profileName: archiveServerId,
|
||||
year: 200,
|
||||
month: 1,
|
||||
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
|
||||
hash: 'archive-1',
|
||||
createdAt: new Date('2026-07-25T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
profileName: archiveServerId,
|
||||
year: 200,
|
||||
month: 2,
|
||||
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
|
||||
hash: 'archive-2',
|
||||
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
|
||||
const authFor = (userId: string): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: '2026-07-25T00:00:00.000Z',
|
||||
expiresAt: '2026-07-26T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (
|
||||
auth: GameSessionTokenPayload | null,
|
||||
options: { hasGeneral?: boolean } = {}
|
||||
): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||
},
|
||||
yearbookHistory: {
|
||||
findFirst: async (args: {
|
||||
where: { profileName: string; year?: number; month?: number };
|
||||
orderBy?: Array<{ year?: 'asc' | 'desc'; month?: 'asc' | 'desc' }>;
|
||||
}) => {
|
||||
const candidates = archiveRows.filter(
|
||||
(row) =>
|
||||
row.profileName === args.where.profileName &&
|
||||
(args.where.year === undefined || row.year === args.where.year) &&
|
||||
(args.where.month === undefined || row.month === args.where.month)
|
||||
);
|
||||
if (!args.orderBy) {
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
const descending = args.orderBy[0]?.year === 'desc';
|
||||
return (descending ? candidates.at(-1) : candidates[0]) ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
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,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('historical yearbook access from dynasty', () => {
|
||||
it('selects the archived server range without treating it as the live month', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
|
||||
|
||||
expect(result).toEqual({
|
||||
firstYearMonth: 200 * 12,
|
||||
lastYearMonth: 200 * 12 + 1,
|
||||
currentYearMonth: 200 * 12 + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the same archived public history for distinct general owners', async () => {
|
||||
const ownerA = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const ownerB = appRouter.createCaller(buildContext(authFor('owner-b')));
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
ownerA.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
||||
ownerB.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
||||
]);
|
||||
|
||||
expect(resultB).toEqual(resultA);
|
||||
expect(resultA).toMatchObject({
|
||||
notModified: false,
|
||||
data: {
|
||||
year: 200,
|
||||
month: 2,
|
||||
nations: [{ id: 1, name: '촉', cities: ['성도'] }],
|
||||
globalHistory: ['<C>●</>2월: 기록 없음'],
|
||||
globalAction: ['<C>●</>2월: 기록 없음'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('requires both an authenticated user and a user-owned general like legacy v_history.php', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const noGeneral = appRouter.createCaller(buildContext(authFor('owner-a'), { hasGeneral: false }));
|
||||
|
||||
await expect(anonymous.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
await expect(noGeneral.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown archived server IDs instead of falling back to the live profile', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
|
||||
await expect(caller.yearbook.getRange({ serverID: 'missing-server' })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
message: '연감 범위를 찾을 수 없습니다.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -711,7 +711,7 @@ export class GeneralAI {
|
||||
const leadership = this.general.stats.leadership;
|
||||
const strength = Math.max(this.general.stats.strength, 1);
|
||||
const intel = Math.max(this.general.stats.intelligence, 1);
|
||||
let genType = 0;
|
||||
let genType: number;
|
||||
|
||||
if (strength >= intel) {
|
||||
genType = t무장;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => {
|
||||
const force = ai.nationPolicy.combatForce[leader.id];
|
||||
let [fromCityId, toCityId] = force;
|
||||
|
||||
let targetCityId: number | null = null;
|
||||
let targetCityId: number | null;
|
||||
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
|
||||
targetCityId = pickRandomCityId(ai, ai.frontCities);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type GamePrisma,
|
||||
GamePrisma,
|
||||
type InputJsonValue,
|
||||
type TurnEngineCityUpdateInput,
|
||||
type TurnEngineDiplomacyCreateManyInput,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type LogEntryDraft,
|
||||
type MessageRecordDraft,
|
||||
} from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
@@ -33,6 +33,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence
|
||||
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||
import { buildPersistedRankRows } from './rankData.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
@@ -270,20 +271,6 @@ const toLegacyDatabaseInt = (value: number): number => {
|
||||
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
|
||||
};
|
||||
|
||||
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return toLegacyDatabaseInt(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return toLegacyDatabaseInt(parsed);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const LEGACY_INTEGER_GENERAL_META_KEYS = [
|
||||
'leadership_exp',
|
||||
'strength_exp',
|
||||
@@ -312,72 +299,33 @@ const buildPersistedGeneralMeta = (
|
||||
return asJson(meta);
|
||||
};
|
||||
|
||||
const buildRankRows = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
|
||||
const meta = asRecord(general.meta);
|
||||
const readMeta = (key: string) => readRankMetaNumber(meta, key);
|
||||
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
|
||||
|
||||
const entries: Array<[RankDataType, number]> = [
|
||||
['experience', toLegacyDatabaseInt(general.experience)],
|
||||
['dedication', toLegacyDatabaseInt(general.dedication)],
|
||||
['firenum', readMeta('firenum')],
|
||||
['warnum', readRank('warnum')],
|
||||
['killnum', readRank('killnum')],
|
||||
['deathnum', readRank('deathnum')],
|
||||
['occupied', readRank('occupied')],
|
||||
['killcrew', readRank('killcrew')],
|
||||
['deathcrew', readRank('deathcrew')],
|
||||
['killcrew_person', readRank('killcrew_person')],
|
||||
['deathcrew_person', readRank('deathcrew_person')],
|
||||
['dex1', readMeta('dex1')],
|
||||
['dex2', readMeta('dex2')],
|
||||
['dex3', readMeta('dex3')],
|
||||
['dex4', readMeta('dex4')],
|
||||
['dex5', readMeta('dex5')],
|
||||
['ttw', readMeta('ttw')],
|
||||
['ttd', readMeta('ttd')],
|
||||
['ttl', readMeta('ttl')],
|
||||
['ttg', readMeta('ttg')],
|
||||
['ttp', readMeta('ttp')],
|
||||
['tlw', readMeta('tlw')],
|
||||
['tld', readMeta('tld')],
|
||||
['tll', readMeta('tll')],
|
||||
['tlg', readMeta('tlg')],
|
||||
['tlp', readMeta('tlp')],
|
||||
['tsw', readMeta('tsw')],
|
||||
['tsd', readMeta('tsd')],
|
||||
['tsl', readMeta('tsl')],
|
||||
['tsg', readMeta('tsg')],
|
||||
['tsp', readMeta('tsp')],
|
||||
['tiw', readMeta('tiw')],
|
||||
['tid', readMeta('tid')],
|
||||
['til', readMeta('til')],
|
||||
['tig', readMeta('tig')],
|
||||
['tip', readMeta('tip')],
|
||||
['betgold', readMeta('betgold')],
|
||||
['betwin', readMeta('betwin')],
|
||||
['betwingold', readMeta('betwingold')],
|
||||
['inherit_earned', readMeta('inherit_earned')],
|
||||
['inherit_spent', readMeta('inherit_spent')],
|
||||
['inherit_earned_dyn', readMeta('inherit_earned_dyn')],
|
||||
['inherit_earned_act', readMeta('inherit_earned_act')],
|
||||
['inherit_spent_dyn', readMeta('inherit_spent_dyn')],
|
||||
];
|
||||
|
||||
return entries.map(([type, value]) => ({
|
||||
generalId: general.id,
|
||||
nationId: general.nationId,
|
||||
type,
|
||||
value,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildInitialRankRows = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
|
||||
buildRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
|
||||
buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
|
||||
|
||||
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||
|
||||
const upsertRankRows = async (
|
||||
prisma: GamePrisma.TransactionClient,
|
||||
rows: Array<{ generalId: number; nationId: number; type: string; value: number }>
|
||||
): Promise<void> => {
|
||||
for (let offset = 0; offset < rows.length; offset += RANK_DATA_UPSERT_BATCH_SIZE) {
|
||||
const batch = rows.slice(offset, offset + RANK_DATA_UPSERT_BATCH_SIZE);
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
INSERT INTO "rank_data" ("general_id", "nation_id", "type", "value")
|
||||
VALUES ${GamePrisma.join(
|
||||
batch.map(
|
||||
(row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})`
|
||||
)
|
||||
)}
|
||||
ON CONFLICT ("general_id", "type") DO UPDATE
|
||||
SET
|
||||
"nation_id" = EXCLUDED."nation_id",
|
||||
"value" = EXCLUDED."value"
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
const buildGeneralUpdate = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
@@ -953,25 +901,9 @@ export const createDatabaseTurnHooks = async (
|
||||
if (createdGenerals.length > 0 || rankTargets.length > 0) {
|
||||
const rankRows = [
|
||||
...createdGenerals.flatMap(buildInitialRankRows),
|
||||
...rankTargets.flatMap(buildRankRows),
|
||||
...rankTargets.flatMap(buildPersistedRankRows),
|
||||
];
|
||||
await Promise.all(
|
||||
rankRows.map((row) =>
|
||||
prisma.rankData.upsert({
|
||||
where: {
|
||||
generalId_type: {
|
||||
generalId: row.generalId,
|
||||
type: row.type,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
nationId: row.nationId,
|
||||
value: row.value,
|
||||
},
|
||||
create: row,
|
||||
})
|
||||
)
|
||||
);
|
||||
await upsertRankRows(prisma, rankRows);
|
||||
}
|
||||
|
||||
if (logs.length > 0) {
|
||||
|
||||
@@ -108,7 +108,7 @@ const applyIncomeOutcome = (
|
||||
originOutcome: number
|
||||
): { next: number; ratio: number; realOutcome: number } => {
|
||||
let next = current + income;
|
||||
let realOutcome = 0;
|
||||
let realOutcome: number;
|
||||
if (next < baseResource) {
|
||||
realOutcome = 0;
|
||||
next = baseResource;
|
||||
@@ -139,14 +139,11 @@ const processIncomeForNation = (
|
||||
const trait = traitMap.get(nation.typeCode) ?? null;
|
||||
const incomeContext = buildNationIncomeContext(nation, trait);
|
||||
|
||||
let income = 0;
|
||||
if (type === 'gold') {
|
||||
income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||
} else {
|
||||
income =
|
||||
getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
|
||||
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||
}
|
||||
const income =
|
||||
type === 'gold'
|
||||
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level)
|
||||
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
|
||||
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||
|
||||
const incomeValue = roundResource(income);
|
||||
const originOutcome = getOutcome(100, nationGenerals);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
LEGACY_RANK_DATA_TYPES,
|
||||
RANK_DATA_TYPES,
|
||||
rankDataMetaKey,
|
||||
type LegacyRankDataType,
|
||||
type RankDataType,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
export interface RankedGeneralState {
|
||||
id: number;
|
||||
nationId: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
meta: unknown;
|
||||
}
|
||||
|
||||
export interface PersistedRankRow {
|
||||
generalId: number;
|
||||
nationId: number;
|
||||
type: RankDataType;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? { ...(value as Record<string, unknown>) }
|
||||
: {};
|
||||
const toLegacyDatabaseInt = (value: number): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
|
||||
};
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return toLegacyDatabaseInt(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? toLegacyDatabaseInt(parsed) : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const rankMetaKey = rankDataMetaKey;
|
||||
|
||||
export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRankRow[] => {
|
||||
const meta = asRecord(general.meta);
|
||||
return RANK_DATA_TYPES.map((type) => {
|
||||
const value =
|
||||
type === 'experience'
|
||||
? toLegacyDatabaseInt(general.experience)
|
||||
: type === 'dedication'
|
||||
? toLegacyDatabaseInt(general.dedication)
|
||||
: readMetaNumber(meta, rankMetaKey(type));
|
||||
return {
|
||||
generalId: general.id,
|
||||
nationId: general.nationId,
|
||||
type,
|
||||
value,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const buildLegacyComparableRankRows = (
|
||||
general: RankedGeneralState
|
||||
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
||||
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
||||
return buildPersistedRankRows(general).filter(
|
||||
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
|
||||
);
|
||||
};
|
||||
|
||||
export const applyPersistedRankRowsToMeta = (
|
||||
rawMeta: Record<string, unknown>,
|
||||
rows: ReadonlyArray<{ type: string; value: number }>
|
||||
): void => {
|
||||
const supportedTypes = new Set<string>(RANK_DATA_TYPES);
|
||||
for (const row of rows) {
|
||||
if (row.type === 'experience' || row.type === 'dedication' || !supportedTypes.has(row.type)) {
|
||||
continue;
|
||||
}
|
||||
rawMeta[rankMetaKey(row.type as RankDataType)] = row.value;
|
||||
}
|
||||
};
|
||||
@@ -35,6 +35,9 @@ 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_NPC_SEIZURE_MESSAGE_PROB = 0.01;
|
||||
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
|
||||
|
||||
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||
@@ -132,6 +135,9 @@ 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),
|
||||
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], DEFAULT_NPC_SEIZURE_MESSAGE_PROB),
|
||||
maxResourceActionAmount: resolveNumber(
|
||||
constValues,
|
||||
['maxResourceActionAmount'],
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
||||
|
||||
@@ -57,6 +57,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
|
||||
import { buildActionContext } from './reservedTurnActionContext.js';
|
||||
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
|
||||
import type { AiReservedTurnProvider } from './ai/types.js';
|
||||
import { rankMetaKey } from './rankData.js';
|
||||
|
||||
const DEFAULT_ACTION = '휴식';
|
||||
|
||||
@@ -227,44 +228,11 @@ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
|
||||
|
||||
const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => {
|
||||
const meta = { ...general.meta };
|
||||
for (const key of [
|
||||
'firenum',
|
||||
'rank_warnum',
|
||||
'rank_killnum',
|
||||
'rank_deathnum',
|
||||
'rank_occupied',
|
||||
'rank_killcrew',
|
||||
'rank_deathcrew',
|
||||
'rank_killcrew_person',
|
||||
'rank_deathcrew_person',
|
||||
'rank_ttw',
|
||||
'rank_ttd',
|
||||
'rank_ttl',
|
||||
'rank_ttg',
|
||||
'rank_ttp',
|
||||
'rank_tlw',
|
||||
'rank_tld',
|
||||
'rank_tll',
|
||||
'rank_tlg',
|
||||
'rank_tlp',
|
||||
'rank_tsw',
|
||||
'rank_tsd',
|
||||
'rank_tsl',
|
||||
'rank_tsg',
|
||||
'rank_tsp',
|
||||
'rank_tiw',
|
||||
'rank_tid',
|
||||
'rank_til',
|
||||
'rank_tig',
|
||||
'rank_tip',
|
||||
'rank_betgold',
|
||||
'rank_betwin',
|
||||
'rank_betwingold',
|
||||
'specage',
|
||||
'specage2',
|
||||
]) {
|
||||
meta[key] = 0;
|
||||
for (const type of LEGACY_RANK_DATA_TYPES) {
|
||||
meta[rankMetaKey(type)] = 0;
|
||||
}
|
||||
meta.specage = 0;
|
||||
meta.specage2 = 0;
|
||||
for (let dex = 1; dex <= 5; dex += 1) {
|
||||
const key = `dex${dex}`;
|
||||
meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5);
|
||||
|
||||
@@ -167,7 +167,8 @@ export const createUnificationHandler = (options: {
|
||||
sabotage,
|
||||
dex,
|
||||
unifier,
|
||||
unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
|
||||
unifierAward:
|
||||
general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -194,15 +195,11 @@ export const createUnificationHandler = (options: {
|
||||
const meta = asRecord(state.meta);
|
||||
|
||||
const serverId =
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim()
|
||||
? meta.serverId.trim()
|
||||
: options.profileName;
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
|
||||
const season = readMetaNumberOrNull(meta, 'season') ?? 1;
|
||||
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
|
||||
const scenarioName =
|
||||
typeof asRecord(meta.scenarioMeta).title === 'string'
|
||||
? String(asRecord(meta.scenarioMeta).title)
|
||||
: '';
|
||||
typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
|
||||
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
|
||||
const unitedTime = new Date().toISOString();
|
||||
|
||||
@@ -307,14 +304,16 @@ export const createUnificationHandler = (options: {
|
||||
};
|
||||
|
||||
for (const [typeName, valueType] of hallTypes) {
|
||||
let value = 0;
|
||||
if (valueType === 'natural') {
|
||||
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0;
|
||||
} else if (valueType === 'rank') {
|
||||
value = ranks[typeName] ?? 0;
|
||||
} else {
|
||||
value = calcValues[typeName] ?? 0;
|
||||
}
|
||||
const value =
|
||||
valueType === 'natural'
|
||||
? typeName === 'experience'
|
||||
? general.experience
|
||||
: typeName === 'dedication'
|
||||
? general.dedication
|
||||
: (ranks[typeName] ?? 0)
|
||||
: valueType === 'rank'
|
||||
? (ranks[typeName] ?? 0)
|
||||
: (calcValues[typeName] ?? 0);
|
||||
|
||||
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
|
||||
continue;
|
||||
@@ -391,9 +390,7 @@ export const createUnificationHandler = (options: {
|
||||
const meta = asRecord(state.meta);
|
||||
|
||||
const serverId =
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim()
|
||||
? meta.serverId.trim()
|
||||
: options.profileName;
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
|
||||
const serverName =
|
||||
typeof meta.serverName === 'string' && meta.serverName.trim()
|
||||
? meta.serverName.trim()
|
||||
@@ -653,30 +650,30 @@ export const createUnificationHandler = (options: {
|
||||
await Promise.all(
|
||||
oldGeneralTargets.map((general) =>
|
||||
((snapshot) =>
|
||||
prisma.oldGeneral.upsert({
|
||||
where: {
|
||||
by_no: {
|
||||
prisma.oldGeneral.upsert({
|
||||
where: {
|
||||
by_no: {
|
||||
serverId,
|
||||
generalNo: general.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
create: {
|
||||
serverId,
|
||||
generalNo: general.id,
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
create: {
|
||||
serverId,
|
||||
generalNo: general.id,
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
}))( {
|
||||
}))({
|
||||
...general,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
})
|
||||
|
||||
@@ -315,8 +315,8 @@ async function handleTournamentMatchResult(
|
||||
const attackerG = getRankNumber(attacker, rankKey('g'));
|
||||
const defenderG = getRankNumber(defender, rankKey('g'));
|
||||
|
||||
let attackerGDelta = 0;
|
||||
let defenderGDelta = 0;
|
||||
let attackerGDelta: number;
|
||||
let defenderGDelta: number;
|
||||
let attackerW = 0;
|
||||
let attackerD = 0;
|
||||
let attackerL = 0;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
|
||||
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
|
||||
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
|
||||
import { readDiplomacyMeta } from '@sammo-ts/logic';
|
||||
import { applyPersistedRankRowsToMeta } from './rankData.js';
|
||||
|
||||
interface TurnWorldLoaderOptions {
|
||||
databaseUrl: string;
|
||||
@@ -157,17 +158,6 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const GENERAL_RANK_META_PREFIX_TYPES = new Set([
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'occupied',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
'killcrew_person',
|
||||
'deathcrew_person',
|
||||
]);
|
||||
|
||||
const mapGeneralRow = (
|
||||
row: TurnEngineGeneralRow,
|
||||
rankRows: readonly TurnEngineRankDataRow[],
|
||||
@@ -181,12 +171,7 @@ const mapGeneralRow = (
|
||||
item: normalizeCode(row.itemCode),
|
||||
};
|
||||
const rawMeta = { ...(asTriggerRecord(row.meta) as Record<string, unknown>) };
|
||||
for (const rank of rankRows) {
|
||||
if (rank.type === 'experience' || rank.type === 'dedication') {
|
||||
continue;
|
||||
}
|
||||
rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value;
|
||||
}
|
||||
applyPersistedRankRowsToMeta(rawMeta, rankRows);
|
||||
const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value]));
|
||||
const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots);
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { rankMetaKey } from '../src/turn/rankData.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||
|
||||
@@ -294,6 +296,9 @@ describe('legacy general turn lifecycle', () => {
|
||||
});
|
||||
|
||||
it('retires a player general and resets inherited stats and rank state', async () => {
|
||||
const legacyRankMeta = Object.fromEntries(
|
||||
LEGACY_RANK_DATA_TYPES.map((type, index) => [rankMetaKey(type), index + 1])
|
||||
);
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot([
|
||||
makeGeneral({
|
||||
@@ -302,11 +307,11 @@ describe('legacy general turn lifecycle', () => {
|
||||
experience: 101,
|
||||
dedication: 81,
|
||||
meta: {
|
||||
...legacyRankMeta,
|
||||
killturn: 24,
|
||||
dex1: 101,
|
||||
inherit_lived_month: 10,
|
||||
inherit_active_action: 4,
|
||||
rank_warnum: 12,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
@@ -325,7 +330,9 @@ describe('legacy general turn lifecycle', () => {
|
||||
expect(updated.meta.dex1).toBe(51);
|
||||
expect(updated.meta.inherit_lived_month).toBe(0);
|
||||
expect(updated.meta.inherit_active_action).toBe(0);
|
||||
expect(updated.meta.rank_warnum).toBe(0);
|
||||
for (const type of LEGACY_RANK_DATA_TYPES) {
|
||||
expect(updated.meta[rankMetaKey(type)], type).toBe(0);
|
||||
}
|
||||
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => {
|
||||
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => {
|
||||
const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
|
||||
const nations = [
|
||||
{
|
||||
@@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
|
||||
}) as never,
|
||||
});
|
||||
|
||||
handler.beforeMonthChanged?.({} as never);
|
||||
await handler.beforeMonthChanged?.({} as never);
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
|
||||
@@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
||||
// Nation awards can occur in the same tick and make the general's net
|
||||
// gold delta smaller than the recruitment price. Exact cost scaling is
|
||||
// covered by the unit-set/action contract tests rather than this smoke.
|
||||
}, 60000);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
@@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, 180000);
|
||||
}, 360_000);
|
||||
});
|
||||
|
||||
@@ -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,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
|
||||
import {
|
||||
applyPersistedRankRowsToMeta,
|
||||
buildLegacyComparableRankRows,
|
||||
buildPersistedRankRows,
|
||||
rankMetaKey,
|
||||
} from '../src/turn/rankData.js';
|
||||
|
||||
describe('rank data projection', () => {
|
||||
it('projects every core row while preserving legacy key and integer semantics', () => {
|
||||
const rows = buildPersistedRankRows({
|
||||
id: 7,
|
||||
nationId: 2,
|
||||
experience: 10.5,
|
||||
dedication: 20.49,
|
||||
meta: {
|
||||
rank_warnum: '3.5',
|
||||
ttw: 4.5,
|
||||
inherit_earned: '9',
|
||||
dex1: 12,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(RANK_DATA_TYPES.length);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ generalId: 7, nationId: 2, type: 'experience', value: 11 },
|
||||
{ generalId: 7, nationId: 2, type: 'dedication', value: 20 },
|
||||
{ generalId: 7, nationId: 2, type: 'warnum', value: 4 },
|
||||
{ generalId: 7, nationId: 2, type: 'ttw', value: 5 },
|
||||
{ generalId: 7, nationId: 2, type: 'inherit_earned', value: 9 },
|
||||
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
|
||||
])
|
||||
);
|
||||
expect(buildLegacyComparableRankRows({
|
||||
id: 7,
|
||||
nationId: 2,
|
||||
experience: 10.5,
|
||||
dedication: 20.49,
|
||||
meta: {},
|
||||
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
|
||||
});
|
||||
|
||||
it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => {
|
||||
const meta: Record<string, unknown> = {};
|
||||
applyPersistedRankRowsToMeta(meta, [
|
||||
{ type: 'warnum', value: 3 },
|
||||
{ type: 'ttw', value: 4 },
|
||||
{ type: 'inherit_spent', value: 5 },
|
||||
{ type: 'dex1', value: 6 },
|
||||
{ type: 'unknown', value: 99 },
|
||||
]);
|
||||
|
||||
expect(meta).toEqual({
|
||||
rank_warnum: 3,
|
||||
ttw: 4,
|
||||
inherit_spent: 5,
|
||||
dex1: 6,
|
||||
});
|
||||
expect(rankMetaKey('warnum')).toBe('rank_warnum');
|
||||
expect(rankMetaKey('betgold')).toBe('betgold');
|
||||
});
|
||||
});
|
||||
@@ -13,5 +13,7 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
maxWorkers: 4,
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
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/game'), resolve(repositoryRoot, '../../image/game')];
|
||||
|
||||
type AuctionFixture = {
|
||||
failResourceBid?: boolean;
|
||||
resourceBidCount: number;
|
||||
uniqueBidCount: number;
|
||||
};
|
||||
|
||||
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 readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||
for (const imageRoot of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(imageRoot, filename));
|
||||
} catch {
|
||||
// The main checkout and nested feature worktrees have different parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
|
||||
const overview = {
|
||||
resourceAuctions: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '1000',
|
||||
status: 'OPEN',
|
||||
hostGeneralId: 11,
|
||||
hostName: '조조',
|
||||
isCallerHost: false,
|
||||
closeAt: '2026-07-27T02:30:00.000Z',
|
||||
detail: {
|
||||
title: '쌀 1000 경매',
|
||||
amount: 1000,
|
||||
isReverse: false,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 1800,
|
||||
},
|
||||
highestBid: {
|
||||
amount: 750,
|
||||
bidderName: '관우',
|
||||
isCaller: false,
|
||||
eventAt: '2026-07-26T01:00:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'SELL_RICE',
|
||||
targetCode: '900',
|
||||
status: 'OPEN',
|
||||
hostGeneralId: 7,
|
||||
hostName: '유비',
|
||||
isCallerHost: true,
|
||||
closeAt: '2026-07-27T03:00:00.000Z',
|
||||
detail: {
|
||||
title: '금 900 경매',
|
||||
amount: 900,
|
||||
isReverse: false,
|
||||
startBidAmount: 600,
|
||||
finishBidAmount: 1700,
|
||||
},
|
||||
highestBid: null,
|
||||
},
|
||||
],
|
||||
uniqueAuctions: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
status: 'OPEN',
|
||||
hostGeneralId: null,
|
||||
hostName: '청룡',
|
||||
isCallerHost: false,
|
||||
closeAt: '2026-07-27T04:00:00.000Z',
|
||||
detail: {
|
||||
title: '칠성검 경매',
|
||||
startBidAmount: 5000,
|
||||
remainCloseDateExtensionCnt: 1,
|
||||
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
|
||||
},
|
||||
highestBid: {
|
||||
amount: 5500,
|
||||
bidderName: '백호',
|
||||
isCaller: false,
|
||||
eventAt: '2026-07-26T02:00:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_서적_15_손자병법',
|
||||
status: 'FINISHED',
|
||||
hostGeneralId: null,
|
||||
hostName: '현무',
|
||||
isCallerHost: true,
|
||||
closeAt: '2026-07-25T04:00:00.000Z',
|
||||
detail: {
|
||||
title: '손자병법 경매',
|
||||
startBidAmount: 5000,
|
||||
remainCloseDateExtensionCnt: 0,
|
||||
availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z',
|
||||
},
|
||||
highestBid: {
|
||||
amount: 6000,
|
||||
bidderName: '현무',
|
||||
isCaller: true,
|
||||
eventAt: '2026-07-25T03:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
callerAlias: '현무',
|
||||
remainPoint: 9000,
|
||||
recentLogs: [
|
||||
{
|
||||
id: 1,
|
||||
text: '<C>●</>경매 1번 거래가 성사되었습니다.',
|
||||
createdAt: '2026-07-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const uniqueDetail = {
|
||||
auction: {
|
||||
id: 10,
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
status: 'OPEN',
|
||||
hostName: '청룡',
|
||||
isCallerHost: false,
|
||||
closeAt: '2026-07-27T04:00:00.000Z',
|
||||
detail: {
|
||||
title: '칠성검 경매',
|
||||
startBidAmount: 5000,
|
||||
remainCloseDateExtensionCnt: 1,
|
||||
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
|
||||
},
|
||||
},
|
||||
bids: [
|
||||
{
|
||||
id: 101,
|
||||
amount: 5500,
|
||||
bidderName: '백호',
|
||||
isCaller: false,
|
||||
eventAt: '2026-07-26T02:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 100,
|
||||
amount: 5000,
|
||||
bidderName: '현무',
|
||||
isCaller: true,
|
||||
eventAt: '2026-07-26T01:00:00.000Z',
|
||||
},
|
||||
],
|
||||
callerAlias: '현무',
|
||||
remainPoint: 9000,
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, state: AuctionFixture) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright');
|
||||
window.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) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await readReferenceImage(filename),
|
||||
});
|
||||
});
|
||||
}
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: { id: 7, name: '유비' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') {
|
||||
return response({});
|
||||
}
|
||||
if (operation === 'auction.getOverview') {
|
||||
return response(overview);
|
||||
}
|
||||
if (operation === 'auction.getUniqueDetail') {
|
||||
return response(uniqueDetail);
|
||||
}
|
||||
if (operation === 'auction.bidBuyRice') {
|
||||
if (state.failResourceBid) {
|
||||
state.failResourceBid = false;
|
||||
return errorResponse(operation, '금이 부족합니다.');
|
||||
}
|
||||
state.resourceBidCount += 1;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'auction.bidUnique') {
|
||||
state.uniqueBidCount += 1;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') {
|
||||
return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const gotoAuction = async (page: Page, suffix = 'auction') => {
|
||||
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
|
||||
await page.goto(suffix);
|
||||
await lobbyResponse;
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
};
|
||||
|
||||
test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => {
|
||||
const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await gotoAuction(page);
|
||||
await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('단가', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const geometry = await page.locator('#container').evaluate((container) => {
|
||||
const box = (selector: string) => {
|
||||
const rect = container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const row = container.querySelector<HTMLElement>('.resource-row')!;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
||||
const button = container.querySelector<HTMLElement>('.tab-button')!;
|
||||
const buttonStyle = getComputedStyle(button);
|
||||
return {
|
||||
container: { x: containerRect.x, width: containerRect.width },
|
||||
topBar: box('.top-back-bar'),
|
||||
row: { width: rowRect.width, height: rowRect.height },
|
||||
cells,
|
||||
button: {
|
||||
height: button.getBoundingClientRect().height,
|
||||
borderRadius: buttonStyle.borderRadius,
|
||||
cursor: buttonStyle.cursor,
|
||||
fontSize: buttonStyle.fontSize,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(geometry.container).toEqual({ x: 0, width: 1000 });
|
||||
expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 });
|
||||
expect(geometry.row).toEqual({ width: 1000, height: 22 });
|
||||
expect(geometry.cells[0]).toBeCloseTo(66.66, 1);
|
||||
expect(geometry.cells[1]).toBeCloseTo(133.34, 1);
|
||||
expect(geometry.cells[6]).toBeCloseTo(200, 1);
|
||||
expect(geometry.button).toEqual({
|
||||
height: 35.5,
|
||||
borderRadius: '5.25px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
});
|
||||
await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true });
|
||||
|
||||
const firstRow = page.locator('.resource-row.clickable-row').first();
|
||||
await firstRow.click();
|
||||
const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' });
|
||||
await bidInput.fill('800');
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('금이 부족합니다.');
|
||||
await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true });
|
||||
expect(state.resourceBidCount).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('입찰했습니다.');
|
||||
expect(state.resourceBidCount).toBe(1);
|
||||
|
||||
await firstRow.hover();
|
||||
expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer');
|
||||
await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => {
|
||||
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
|
||||
await page.setViewportSize({ width: 500, height: 800 });
|
||||
await gotoAuction(page);
|
||||
|
||||
const geometry = await page
|
||||
.locator('.resource-row')
|
||||
.first()
|
||||
.evaluate((row) => {
|
||||
const origin = row.getBoundingClientRect();
|
||||
const relative = (selector: string) => {
|
||||
const rect = row.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
return {
|
||||
row: { width: origin.width, height: origin.height },
|
||||
idx: relative('.idx'),
|
||||
host: relative('.host'),
|
||||
amount: relative('.amount'),
|
||||
close: relative('.close-date'),
|
||||
};
|
||||
});
|
||||
expect(geometry.row).toEqual({ width: 500, height: 43 });
|
||||
expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 });
|
||||
expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 });
|
||||
expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 });
|
||||
expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 });
|
||||
await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => {
|
||||
const state = { resourceBidCount: 0, uniqueBidCount: 0 };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await gotoAuction(page, 'auction?type=unique');
|
||||
|
||||
await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무');
|
||||
await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible();
|
||||
await expect(page.getByText('최대지연', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible();
|
||||
await expect(page.getByText('남음', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('소진', { exact: true })).toBeVisible();
|
||||
|
||||
const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { color: style.color, fontWeight: style.fontWeight };
|
||||
});
|
||||
expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' });
|
||||
|
||||
const input = page.getByRole('spinbutton', { name: '유산포인트' });
|
||||
await input.fill('5600');
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.');
|
||||
expect(state.uniqueBidCount).toBe(1);
|
||||
await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('resource host cannot bid on the auction opened by its own general', async ({ page }) => {
|
||||
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
|
||||
await gotoAuction(page);
|
||||
|
||||
await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click();
|
||||
await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled();
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -141,7 +141,8 @@ const parseSort = (route: Route): number => {
|
||||
|
||||
const install = async (
|
||||
page: Page,
|
||||
mode: 'general' | 'no-general' | 'error-after-load' = 'general'
|
||||
mode: 'general' | 'no-general' | 'error-after-load' = 'general',
|
||||
accessPages: string[] = []
|
||||
) => {
|
||||
let generalDirectoryCalls = 0;
|
||||
await page.addInitScript(() => {
|
||||
@@ -159,12 +160,21 @@ const install = async (
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
| Record<string, { json?: { page?: unknown }; page?: unknown }>
|
||||
| undefined;
|
||||
const results = operationNames(route).map((operation, operationIndex) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'world.getNationDirectory') return response(nationDirectory);
|
||||
if (operation === 'public.recordAccess') {
|
||||
const payload = requestBody?.[String(operationIndex)];
|
||||
const pageName = payload?.json?.page ?? payload?.page;
|
||||
if (typeof pageName === 'string') accessPages.push(pageName);
|
||||
return response({ recorded: true });
|
||||
}
|
||||
if (operation === 'world.getGeneralDirectory') {
|
||||
generalDirectoryCalls += 1;
|
||||
if (mode === 'error-after-load' && generalDirectoryCalls > 1) {
|
||||
@@ -191,7 +201,8 @@ const install = async (
|
||||
};
|
||||
|
||||
test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => {
|
||||
await install(page);
|
||||
const accessPages: string[] = [];
|
||||
await install(page, 'general', accessPages);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
const measurements: Record<string, unknown> = {};
|
||||
@@ -272,6 +283,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
||||
).toBe(65);
|
||||
}
|
||||
}
|
||||
await expect.poll(() => accessPages).toEqual(expect.arrayContaining(['nation-list', 'general-list']));
|
||||
|
||||
const header = page.locator('.general-table thead td').first();
|
||||
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
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>>;
|
||||
accessPages: string[];
|
||||
};
|
||||
|
||||
type TrpcRequestPayload = {
|
||||
json?: Record<string, unknown>;
|
||||
input?: { json?: 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', 'ga_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 rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||
const requestBody =
|
||||
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||||
const results = operations.map((operation, operationIndex) => {
|
||||
const rawPayload =
|
||||
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
|
||||
const payload =
|
||||
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
||||
const jsonInput =
|
||||
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
||||
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') {
|
||||
state.settingMutations.push(jsonInput);
|
||||
state.myset = Math.max(0, state.myset - 1);
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'public.recordAccess') {
|
||||
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
||||
if (pageName) state.accessPages.push(pageName);
|
||||
return response({ recorded: 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: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('traffic');
|
||||
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
|
||||
await expect.poll(() => state.accessPages).toContain('traffic');
|
||||
|
||||
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: [], accessPages: [] };
|
||||
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();
|
||||
await expect.poll(() => state.accessPages).toContain('my-page');
|
||||
|
||||
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: [], accessPages: [] };
|
||||
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: [], accessPages: [] };
|
||||
await install(page, member);
|
||||
await page.reload();
|
||||
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -12,9 +12,14 @@ export default defineConfig({
|
||||
'troop.spec.ts',
|
||||
'board.spec.ts',
|
||||
'inGameInfo.spec.ts',
|
||||
'inGameMenus.spec.ts',
|
||||
'nationOffices.spec.ts',
|
||||
'directoryLists.spec.ts',
|
||||
'nationGeneralSecret.spec.ts',
|
||||
'npcPolicy.spec.ts',
|
||||
'auction.spec.ts',
|
||||
'battleSimulator.spec.ts',
|
||||
'battleSimulatorRef.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
"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:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:auction": "playwright test auction.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')\"",
|
||||
|
||||
@@ -3,13 +3,14 @@ import { computed, ref } from 'vue';
|
||||
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
|
||||
|
||||
interface Props {
|
||||
general: GeneralDraft;
|
||||
options: BattleSimOptions;
|
||||
mode: 'attacker' | 'defender';
|
||||
title: string;
|
||||
canImportServer: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const general = defineModel<GeneralDraft>('general', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'import'): void;
|
||||
@@ -59,7 +60,19 @@ const officerLevelOptions = [
|
||||
<div class="general-subtitle">No {{ general.no }}</div>
|
||||
</div>
|
||||
<div class="general-actions">
|
||||
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
|
||||
<button
|
||||
class="action"
|
||||
type="button"
|
||||
:disabled="!canImportServer"
|
||||
:title="
|
||||
canImportServer
|
||||
? '게임 서버의 장수 정보를 불러옵니다.'
|
||||
: '게임 장수를 보유해야 사용할 수 있습니다.'
|
||||
"
|
||||
@click="emit('import')"
|
||||
>
|
||||
서버에서 가져오기
|
||||
</button>
|
||||
<button class="action" type="button" @click="emit('save')">저장</button>
|
||||
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
||||
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
||||
@@ -187,21 +200,11 @@ const officerLevelOptions = [
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>훈련</span>
|
||||
<input
|
||||
v-model.number="general.train"
|
||||
type="number"
|
||||
min="40"
|
||||
:max="options.config.maxTrainByWar"
|
||||
/>
|
||||
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>사기</span>
|
||||
<input
|
||||
v-model.number="general.atmos"
|
||||
type="number"
|
||||
min="40"
|
||||
:max="options.config.maxAtmosByWar"
|
||||
/>
|
||||
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>전특</span>
|
||||
@@ -308,30 +311,15 @@ const officerLevelOptions = [
|
||||
<div class="form-row buff-row">
|
||||
<label class="field">
|
||||
<span>상대 회피</span>
|
||||
<input
|
||||
v-model.number="general.inheritBuff.warAvoidRatioOppose"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
/>
|
||||
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>상대 필살</span>
|
||||
<input
|
||||
v-model.number="general.inheritBuff.warCriticalRatioOppose"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
/>
|
||||
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>상대 계략</span>
|
||||
<input
|
||||
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
/>
|
||||
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,6 +379,11 @@ const officerLevelOptions = [
|
||||
color: #f0b6b6;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.form-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,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">서신전달&갱신</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"><{{ message.time }}></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>
|
||||
@@ -21,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';
|
||||
@@ -35,7 +34,38 @@ import NationBettingView from '../views/NationBettingView.vue';
|
||||
import NpcListView from '../views/NpcListView.vue';
|
||||
import NationListView from '../views/NationListView.vue';
|
||||
import GeneralListView from '../views/GeneralListView.vue';
|
||||
import TrafficView from '../views/TrafficView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const accessPageByRouteName = {
|
||||
home: 'front-info',
|
||||
'nation-info': 'nation-info',
|
||||
'nation-cities': 'nation-cities',
|
||||
'global-info': 'global-info',
|
||||
'nation-list': 'nation-list',
|
||||
'general-list': 'general-list',
|
||||
'current-city': 'current-city',
|
||||
diplomacy: 'diplomacy',
|
||||
'nation-generals': 'nation-generals',
|
||||
'nation-personnel': 'nation-personnel',
|
||||
'nation-finance': 'nation-finance',
|
||||
'battle-center': 'battle-center',
|
||||
board: 'board',
|
||||
'board-secret': 'board',
|
||||
'best-general': 'best-general',
|
||||
'hall-of-fame': 'hall-of-fame',
|
||||
'dynasty-list': 'dynasty',
|
||||
'dynasty-detail': 'dynasty',
|
||||
yearbook: 'yearbook',
|
||||
'nation-betting': 'nation-betting',
|
||||
traffic: 'traffic',
|
||||
'npc-list': 'npc-list',
|
||||
'my-page': 'my-page',
|
||||
'npc-control': 'npc-control',
|
||||
tournament: 'tournament',
|
||||
betting: 'betting',
|
||||
} as const;
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -211,7 +241,6 @@ const routes = [
|
||||
component: BattleSimulatorView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -261,6 +290,7 @@ const routes = [
|
||||
component: YearbookView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -272,6 +302,11 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/traffic',
|
||||
name: 'traffic',
|
||||
component: TrafficView,
|
||||
},
|
||||
{
|
||||
path: '/npc-list',
|
||||
name: 'npc-list',
|
||||
@@ -297,8 +332,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/my-settings',
|
||||
name: 'my-settings',
|
||||
component: MySettingsView,
|
||||
redirect: '/my-page',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
@@ -381,4 +415,14 @@ router.beforeEach(async (to) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
const session = useSessionStore();
|
||||
const routeName = typeof to.name === 'string' ? to.name : '';
|
||||
const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName];
|
||||
if (!page || !session.hasGeneral) {
|
||||
return;
|
||||
}
|
||||
void trpc.public.recordAccess.mutate({ page }).catch(() => undefined);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => {
|
||||
return '';
|
||||
}
|
||||
|
||||
let match: RegExpExecArray | null = null;
|
||||
let lastIndex = 0;
|
||||
const result: string[] = [];
|
||||
|
||||
while ((match = logRegex.exec(text)) !== null) {
|
||||
for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) {
|
||||
const partAll = match[0];
|
||||
const subPart = match[1];
|
||||
const index = match.index;
|
||||
@@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => {
|
||||
if (subPart === '/') {
|
||||
result.push('</span>');
|
||||
} else if (subPart.length === 2) {
|
||||
result.push(
|
||||
`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`
|
||||
);
|
||||
result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
|
||||
} else {
|
||||
result.push(`<span style="${convertMap[subPart] ?? ''}">`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const lightTextBackgrounds = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#6495ED',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#800080',
|
||||
'#A9A9A9',
|
||||
'#000000',
|
||||
]);
|
||||
|
||||
export const legacyNationTextColor = (backgroundColor: string): '#FFFFFF' | '#000000' =>
|
||||
lightTextBackgrounds.has(backgroundColor.toUpperCase()) ? '#FFFFFF' : '#000000';
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
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 { formatLog } from '../utils/formatLog';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -11,7 +10,8 @@ type ResourceAuction = AuctionOverview['resourceAuctions'][number];
|
||||
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
|
||||
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
|
||||
|
||||
const activeTab = ref<'resource' | 'unique'>('resource');
|
||||
const route = useRoute();
|
||||
const activeTab = ref<'resource' | 'unique'>(route.query.type === 'unique' ? 'unique' : 'resource');
|
||||
const loading = ref(false);
|
||||
const actionBusy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -38,22 +38,57 @@ const resolveErrorMessage = (value: unknown): string => {
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
|
||||
const formatDate = (value: string): string =>
|
||||
new Intl.DateTimeFormat('ko-KR', {
|
||||
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.slice(5, showSecond ? 19 : 16);
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(new Date(value));
|
||||
...(showSecond ? { second: '2-digit' } : {}),
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((entry) => entry.type === type)?.value ?? '';
|
||||
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
|
||||
};
|
||||
|
||||
const resourceTitle = (auction: ResourceAuction): string =>
|
||||
auction.type === 'BUY_RICE' ? '쌀 구매' : '쌀 판매';
|
||||
const hostResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '쌀' : '금');
|
||||
const bidResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '금' : '쌀');
|
||||
const buyRice = computed(() =>
|
||||
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'BUY_RICE')
|
||||
);
|
||||
const sellRice = computed(() =>
|
||||
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'SELL_RICE')
|
||||
);
|
||||
const ongoingUnique = computed(() =>
|
||||
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status === 'OPEN')
|
||||
);
|
||||
const finishedUnique = computed(() =>
|
||||
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status !== 'OPEN')
|
||||
);
|
||||
|
||||
const resourceAuctions = computed(() => overview.value?.resourceAuctions ?? []);
|
||||
const uniqueAuctions = computed(() => overview.value?.uniqueAuctions ?? []);
|
||||
const selectResource = (auction: ResourceAuction): void => {
|
||||
selectedResource.value = auction;
|
||||
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||
};
|
||||
|
||||
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
|
||||
selectedUnique.value = auction;
|
||||
uniqueDetail.value = null;
|
||||
error.value = null;
|
||||
try {
|
||||
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
|
||||
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOverview = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
@@ -65,8 +100,14 @@ const loadOverview = async (): Promise<void> => {
|
||||
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
|
||||
}
|
||||
if (selectedUnique.value) {
|
||||
selectedUnique.value =
|
||||
const updated =
|
||||
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
|
||||
selectedUnique.value = updated;
|
||||
if (updated) {
|
||||
await selectUnique(updated);
|
||||
}
|
||||
} else if (activeTab.value === 'unique' && ongoingUnique.value[0]) {
|
||||
await selectUnique(ongoingUnique.value[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
@@ -75,23 +116,6 @@ const loadOverview = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectResource = (auction: ResourceAuction): void => {
|
||||
selectedResource.value = auction;
|
||||
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||
};
|
||||
|
||||
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
|
||||
selectedUnique.value = auction;
|
||||
error.value = null;
|
||||
try {
|
||||
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
|
||||
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||
if (actionBusy.value) {
|
||||
return;
|
||||
@@ -138,7 +162,7 @@ const bidResourceAuction = (): Promise<void> =>
|
||||
} else {
|
||||
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
||||
}
|
||||
message.value = `${auction.id}번 경매에 입찰했습니다.`;
|
||||
message.value = '입찰했습니다.';
|
||||
});
|
||||
|
||||
const bidUniqueAuction = (): Promise<void> =>
|
||||
@@ -147,206 +171,669 @@ const bidUniqueAuction = (): Promise<void> =>
|
||||
if (!auction) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`${auction.detail.title ?? auction.targetCode ?? '유니크'}에 ${bidAmount.value} 포인트를 입찰하시겠습니까?`)) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`${auction.detail.title ?? auction.targetCode ?? '유니크'}에 ${bidAmount.value}유산포인트를 입찰하시겠습니까?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await trpc.auction.bidUnique.mutate({
|
||||
auctionId: auction.id,
|
||||
amount: bidAmount.value,
|
||||
tryExtendCloseDate: true,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
message.value = `${auction.id}번 유니크 경매에 입찰했습니다.`;
|
||||
await selectUnique(auction);
|
||||
message.value = '입찰이 완료되었습니다.';
|
||||
});
|
||||
|
||||
const closeWindow = (): void => window.close();
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
error.value = null;
|
||||
message.value = null;
|
||||
if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) {
|
||||
void selectUnique(ongoingUnique.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadOverview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auction-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>거래장</h1>
|
||||
<p>금·쌀 거래와 유니크 아이템 경매를 확인합니다.</p>
|
||||
</div>
|
||||
<button class="ghost" :disabled="loading" @click="loadOverview">새로고침</button>
|
||||
<main id="container" class="legacy-auction-page bg0">
|
||||
<header class="top-back-bar bg0">
|
||||
<button class="legacy-button close-button" type="button" @click="closeWindow">창 닫기</button>
|
||||
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview">
|
||||
갱신
|
||||
</button>
|
||||
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
|
||||
<button
|
||||
class="legacy-button tab-button"
|
||||
:aria-pressed="activeTab === 'resource'"
|
||||
@click="activeTab = 'resource'"
|
||||
>
|
||||
금/쌀
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button tab-button"
|
||||
:aria-pressed="activeTab === 'unique'"
|
||||
@click="activeTab = 'unique'"
|
||||
>
|
||||
유니크
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="경매 종류">
|
||||
<button :class="{ active: activeTab === 'resource' }" @click="activeTab = 'resource'">금·쌀 경매</button>
|
||||
<button :class="{ active: activeTab === 'unique' }" @click="activeTab = 'unique'">유니크 경매</button>
|
||||
</nav>
|
||||
<p v-if="error" class="auction-notice error" role="alert">{{ error }}</p>
|
||||
<p v-if="message" class="auction-notice success" role="status">{{ message }}</p>
|
||||
<div v-if="loading && !overview" class="loading-state">불러오는 중...</div>
|
||||
|
||||
<p v-if="error" class="notice error">{{ error }}</p>
|
||||
<p v-if="message" class="notice success">{{ message }}</p>
|
||||
<SkeletonLines v-if="loading && !overview" :lines="8" />
|
||||
<section v-else-if="activeTab === 'resource'" class="resource-auction bg0">
|
||||
<h2 class="section-title bg2">거래장</h2>
|
||||
|
||||
<template v-else-if="activeTab === 'resource'">
|
||||
<PanelCard title="진행 중인 금·쌀 경매" subtitle="행을 선택하면 아래에서 입찰할 수 있습니다.">
|
||||
<div class="auction-table resource-table">
|
||||
<div class="table-head">
|
||||
<span>번호</span><span>종류</span><span>판매자</span><span>수량</span><span>입찰자</span>
|
||||
<span>현재가</span><span>마감가</span><span>종료</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="auction in resourceAuctions"
|
||||
:key="auction.id"
|
||||
class="table-row"
|
||||
:class="{ selected: selectedResource?.id === auction.id }"
|
||||
@click="selectResource(auction)"
|
||||
>
|
||||
<span>{{ auction.id }}</span>
|
||||
<span>{{ resourceTitle(auction) }}</span>
|
||||
<span>{{ auction.hostName }}</span>
|
||||
<span>{{ hostResource(auction) }} {{ formatNumber(auction.detail.amount) }}</span>
|
||||
<span>{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||
<span>{{ bidResource(auction) }} {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
||||
<span>{{ bidResource(auction) }} {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
||||
<span>{{ formatDate(auction.closeAt) }}</span>
|
||||
</button>
|
||||
<p v-if="resourceAuctions.length === 0" class="empty">진행 중인 경매가 없습니다.</p>
|
||||
<section class="resource-section" aria-labelledby="buy-rice-heading">
|
||||
<h3 id="buy-rice-heading" class="resource-kind buy-rice">쌀 구매</h3>
|
||||
<div class="resource-row resource-header">
|
||||
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
|
||||
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰가</span>
|
||||
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
|
||||
<span class="close-date">거래 종료</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="auction in buyRice"
|
||||
:key="auction.id"
|
||||
class="resource-row clickable-row"
|
||||
:class="{ selected: selectedResource?.id === auction.id }"
|
||||
@click="selectResource(auction)"
|
||||
>
|
||||
<span class="idx tnum">{{ auction.id }}</span>
|
||||
<span class="host">{{ auction.hostName }}</span>
|
||||
<span class="amount tnum">쌀 {{ formatNumber(auction.detail.amount) }}</span>
|
||||
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
|
||||
금 {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
|
||||
</span>
|
||||
<span class="bid-ratio tnum">
|
||||
{{
|
||||
auction.highestBid && auction.detail.amount
|
||||
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
|
||||
: '-'
|
||||
}}
|
||||
</span>
|
||||
<span class="finish-bid tnum">금 {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
||||
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||
</button>
|
||||
<p v-if="buyRice.length === 0" class="empty-row">진행 중인 쌀 구매 경매가 없습니다.</p>
|
||||
</section>
|
||||
|
||||
<form v-if="selectedResource" class="bid-form" @submit.prevent="bidResourceAuction">
|
||||
<strong>{{ selectedResource.id }}번 {{ resourceTitle(selectedResource) }}</strong>
|
||||
<label>
|
||||
<span>입찰가 ({{ bidResource(selectedResource) }})</span>
|
||||
<input v-model.number="bidAmount" type="number" min="1" step="10" required />
|
||||
</label>
|
||||
<button :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
|
||||
</form>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="경매 등록" subtitle="레거시와 동일하게 한 장수는 자원 경매를 한 건만 진행할 수 있습니다.">
|
||||
<form class="open-form" @submit.prevent="openResourceAuction">
|
||||
<label>
|
||||
<span>매물</span>
|
||||
<select v-model="openForm.type">
|
||||
<option value="BUY_RICE">쌀</option>
|
||||
<option value="SELL_RICE">금</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>수량</span><input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" /></label>
|
||||
<label><span>기간(턴)</span><input v-model.number="openForm.closeTurnCnt" type="number" min="1" max="24" /></label>
|
||||
<label><span>시작가</span><input v-model.number="openForm.startBidAmount" type="number" min="1" step="10" /></label>
|
||||
<label><span>마감가</span><input v-model.number="openForm.finishBidAmount" type="number" min="1" step="10" /></label>
|
||||
<button :disabled="actionBusy">등록</button>
|
||||
</form>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="이전 경매" subtitle="최근 경매 기록 20건">
|
||||
<ol class="log-list">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<li v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<li v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty">경매 기록이 없습니다.</li>
|
||||
</ol>
|
||||
</PanelCard>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<PanelCard title="유니크 경매" :subtitle="`내 가명: ${overview?.callerAlias ?? '-'}`">
|
||||
<div class="auction-table unique-table">
|
||||
<div class="table-head">
|
||||
<span>번호</span><span>경매명</span><span>주최자</span><span>종료</span><span>1순위</span><span>포인트</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="auction in uniqueAuctions"
|
||||
:key="auction.id"
|
||||
class="table-row"
|
||||
:class="{ selected: selectedUnique?.id === auction.id }"
|
||||
@click="selectUnique(auction)"
|
||||
>
|
||||
<span>{{ auction.id }}</span>
|
||||
<span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
||||
<span :class="{ me: auction.isCallerHost }">{{ auction.hostName }}</span>
|
||||
<span>{{ formatDate(auction.closeAt) }}</span>
|
||||
<span :class="{ me: auction.highestBid?.isCaller }">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||
<span>{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
||||
</button>
|
||||
<p v-if="uniqueAuctions.length === 0" class="empty">유니크 경매가 없습니다.</p>
|
||||
<section class="resource-section" aria-labelledby="sell-rice-heading">
|
||||
<h3 id="sell-rice-heading" class="resource-kind sell-rice">쌀 판매</h3>
|
||||
<div class="resource-row resource-header">
|
||||
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
|
||||
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰가</span>
|
||||
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
|
||||
<span class="close-date">거래 종료</span>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<button
|
||||
v-for="auction in sellRice"
|
||||
:key="auction.id"
|
||||
class="resource-row clickable-row"
|
||||
:class="{ selected: selectedResource?.id === auction.id }"
|
||||
@click="selectResource(auction)"
|
||||
>
|
||||
<span class="idx tnum">{{ auction.id }}</span>
|
||||
<span class="host">{{ auction.hostName }}</span>
|
||||
<span class="amount tnum">금 {{ formatNumber(auction.detail.amount) }}</span>
|
||||
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
|
||||
쌀 {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
|
||||
</span>
|
||||
<span class="bid-ratio tnum">
|
||||
{{
|
||||
auction.highestBid && auction.detail.amount
|
||||
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
|
||||
: '-'
|
||||
}}
|
||||
</span>
|
||||
<span class="finish-bid tnum">쌀 {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
||||
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||
</button>
|
||||
<p v-if="sellRice.length === 0" class="empty-row">진행 중인 쌀 판매 경매가 없습니다.</p>
|
||||
</section>
|
||||
|
||||
<PanelCard v-if="uniqueDetail" title="유니크 경매 상세">
|
||||
<form v-if="selectedResource" class="resource-bid-form" @submit.prevent="bidResourceAuction">
|
||||
<span class="bid-description">
|
||||
{{ selectedResource.id }}번 {{ selectedResource.type === 'BUY_RICE' ? '쌀' : '금' }}
|
||||
{{ formatNumber(selectedResource.detail.amount) }} 경매에
|
||||
{{ selectedResource.type === 'BUY_RICE' ? '금' : '쌀' }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="bidAmount"
|
||||
:aria-label="`${selectedResource.id}번 경매 입찰가`"
|
||||
type="number"
|
||||
:min="selectedResource.detail.startBidAmount ?? 1"
|
||||
:max="selectedResource.detail.finishBidAmount ?? undefined"
|
||||
step="10"
|
||||
required
|
||||
/>
|
||||
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
|
||||
</form>
|
||||
|
||||
<h3 class="subsection-title">경매 등록</h3>
|
||||
<form class="open-form" @submit.prevent="openResourceAuction">
|
||||
<fieldset>
|
||||
<legend>매물</legend>
|
||||
<div class="item-toggle">
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="button"
|
||||
:aria-pressed="openForm.type === 'BUY_RICE'"
|
||||
@click="openForm.type = 'BUY_RICE'"
|
||||
>
|
||||
쌀
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="button"
|
||||
:aria-pressed="openForm.type === 'SELL_RICE'"
|
||||
@click="openForm.type = 'SELL_RICE'"
|
||||
>
|
||||
금
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<label>
|
||||
<span>수량 ({{ openForm.type === 'BUY_RICE' ? '쌀' : '금' }})</span>
|
||||
<input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" />
|
||||
</label>
|
||||
<label
|
||||
><span>기간(턴)</span><input v-model.number="openForm.closeTurnCnt" type="number" min="3" max="24"
|
||||
/></label>
|
||||
<label>
|
||||
<span>시작가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
|
||||
<input v-model.number="openForm.startBidAmount" type="number" min="100" max="10000" step="10" />
|
||||
</label>
|
||||
<label>
|
||||
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
|
||||
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
|
||||
</label>
|
||||
<button class="legacy-button register-button" :disabled="actionBusy">등록</button>
|
||||
</form>
|
||||
|
||||
<h3 class="subsection-title">이전 경매(최근 20건)</h3>
|
||||
<div class="recent-logs">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<div v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty-row">경매 기록이 없습니다.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="unique-auction bg0">
|
||||
<div class="caller-alias">
|
||||
내 가명: <strong>{{ overview?.callerAlias ?? '-' }}</strong>
|
||||
</div>
|
||||
|
||||
<section v-if="uniqueDetail" class="unique-detail">
|
||||
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }}번 상세</h2>
|
||||
<dl class="detail-grid">
|
||||
<dt>경매명</dt><dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
|
||||
<dt>주최자(익명)</dt><dd :class="{ me: uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
|
||||
<dt>종료일시</dt><dd>{{ formatDate(uniqueDetail.auction.closeAt) }}</dd>
|
||||
<dt>잔여 포인트</dt><dd>{{ formatNumber(uniqueDetail.remainPoint) }}</dd>
|
||||
<dt class="bg1">경매명</dt>
|
||||
<dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
|
||||
<dt class="bg1">주최자(익명)</dt>
|
||||
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
|
||||
<dt class="bg1">종료일시</dt>
|
||||
<dd class="tnum">{{ cutDateTime(uniqueDetail.auction.closeAt, true) }}</dd>
|
||||
<dt class="bg1">최대지연</dt>
|
||||
<dd class="tnum">
|
||||
{{ cutDateTime(uniqueDetail.auction.detail.availableLatestBidCloseDate, true) }}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="bid-history">
|
||||
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-entry">
|
||||
<span :class="{ me: bid.isCaller }">{{ bid.bidderName }}</span>
|
||||
<strong>{{ formatNumber(bid.amount) }}</strong>
|
||||
<time>{{ formatDate(bid.eventAt) }}</time>
|
||||
</div>
|
||||
<h3 class="subsection-title bg1">입찰자 목록</h3>
|
||||
<div class="bid-row bid-header"><span>입찰자</span><span>입찰포인트</span><span>시각</span></div>
|
||||
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
|
||||
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
|
||||
<span class="tnum">{{ formatNumber(bid.amount) }}</span>
|
||||
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time>
|
||||
</div>
|
||||
<form v-if="uniqueDetail.auction.status === 'OPEN'" class="bid-form" @submit.prevent="bidUniqueAuction">
|
||||
<label><span>유산 포인트</span><input v-model.number="bidAmount" type="number" min="1" required /></label>
|
||||
<button :disabled="actionBusy">입찰</button>
|
||||
</form>
|
||||
</PanelCard>
|
||||
</template>
|
||||
<template v-if="uniqueDetail.auction.status === 'OPEN'">
|
||||
<h3 class="subsection-title bg1">입찰하기</h3>
|
||||
<form class="unique-bid-form" @submit.prevent="bidUniqueAuction">
|
||||
<label for="unique-bid">
|
||||
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
|
||||
</label>
|
||||
<input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required />
|
||||
<button class="legacy-button" :disabled="actionBusy">입찰</button>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="unique-list-section">
|
||||
<h2 class="subsection-title bg1">진행중인 경매 목록</h2>
|
||||
<div class="unique-row unique-header">
|
||||
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
|
||||
><span>1순위</span><span>포인트</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="auction in ongoingUnique"
|
||||
:key="auction.id"
|
||||
class="unique-row clickable-row"
|
||||
:class="{ selected: selectedUnique?.id === auction.id }"
|
||||
@click="selectUnique(auction)"
|
||||
>
|
||||
<span>{{ auction.id }}</span
|
||||
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
||||
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
|
||||
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
|
||||
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
|
||||
auction.highestBid?.bidderName ?? '-'
|
||||
}}</span>
|
||||
<span class="tnum">{{
|
||||
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
|
||||
}}</span>
|
||||
</button>
|
||||
<p v-if="ongoingUnique.length === 0" class="empty-row">진행중인 유니크 경매가 없습니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="unique-list-section">
|
||||
<h2 class="subsection-title bg1">종료된 경매 목록</h2>
|
||||
<div class="unique-row unique-header">
|
||||
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
|
||||
><span>1순위</span><span>포인트</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="auction in finishedUnique"
|
||||
:key="auction.id"
|
||||
class="unique-row clickable-row"
|
||||
:class="{ selected: selectedUnique?.id === auction.id }"
|
||||
@click="selectUnique(auction)"
|
||||
>
|
||||
<span>{{ auction.id }}</span
|
||||
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
||||
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
|
||||
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
|
||||
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
|
||||
auction.highestBid?.bidderName ?? '-'
|
||||
}}</span>
|
||||
<span class="tnum">{{
|
||||
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
|
||||
}}</span>
|
||||
</button>
|
||||
<p v-if="finishedUnique.length === 0" class="empty-row">종료된 유니크 경매가 없습니다.</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer class="bottom-bar bg0">
|
||||
<button class="legacy-button close-button" type="button" @click="closeWindow">창 닫기</button>
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auction-page {
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: #e8ddc4;
|
||||
background: radial-gradient(circle at top, rgba(93, 57, 26, 0.25), transparent 42%), #080807;
|
||||
.legacy-auction-page {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
}
|
||||
.page-header, .tabs, .bid-form, .open-form, .detail-grid, .bid-entry {
|
||||
display: flex;
|
||||
.bg0 {
|
||||
background-color: #302016;
|
||||
background-image: url('/image/game/back_walnut.jpg');
|
||||
}
|
||||
.bg1 {
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
}
|
||||
.bg2 {
|
||||
background-color: #172a52;
|
||||
background-image: url('/image/game/back_blue.jpg');
|
||||
}
|
||||
.top-back-bar {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 90px 90px 1fr 90px 90px;
|
||||
}
|
||||
.top-back-bar h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.legacy-button {
|
||||
box-sizing: border-box;
|
||||
border: solid #3d3d3d;
|
||||
border-width: 0 1px 4px;
|
||||
border-radius: 5.25px;
|
||||
padding: 5.25px 10.5px;
|
||||
color: #fff;
|
||||
background: #444;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 21px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.legacy-button:hover,
|
||||
.legacy-button:focus {
|
||||
border-color: #353535;
|
||||
background: #393939;
|
||||
}
|
||||
.legacy-button:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.legacy-button:active,
|
||||
.legacy-button[aria-pressed='true'] {
|
||||
border-color: #303030;
|
||||
background: #333;
|
||||
}
|
||||
.legacy-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.close-button,
|
||||
.reload-button {
|
||||
margin-right: 2px;
|
||||
border-color: #004f28;
|
||||
background: #00582c;
|
||||
}
|
||||
.close-button:hover,
|
||||
.close-button:focus,
|
||||
.reload-button:hover,
|
||||
.reload-button:focus {
|
||||
border-color: #004523;
|
||||
background: #004a25;
|
||||
}
|
||||
.top-back-bar .close-button,
|
||||
.top-back-bar .reload-button {
|
||||
height: 32px;
|
||||
}
|
||||
.tab-button {
|
||||
border-color: #3d3d3d;
|
||||
background: #444;
|
||||
}
|
||||
.tab-button[aria-pressed='true'] {
|
||||
border-color: #3d3d3d;
|
||||
background: #444;
|
||||
}
|
||||
.tab-button:hover,
|
||||
.tab-button:focus {
|
||||
border-color: #353535;
|
||||
background: #393939;
|
||||
}
|
||||
.section-title,
|
||||
.subsection-title,
|
||||
.resource-kind {
|
||||
margin: 0;
|
||||
min-height: 18px;
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
.resource-kind.buy-rice {
|
||||
color: #000;
|
||||
background: orange;
|
||||
}
|
||||
.resource-kind.sell-rice {
|
||||
color: #000;
|
||||
background: skyblue;
|
||||
}
|
||||
.resource-row {
|
||||
width: 100%;
|
||||
min-height: 22px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 2fr 2fr 2fr 1fr 3fr 2fr;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-bottom: 1px solid gray;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.page-header { justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.page-header h1 { margin: 0; font-size: 1.45rem; }
|
||||
.page-header p { margin: 4px 0 0; color: rgba(232, 221, 196, 0.7); }
|
||||
.tabs { gap: 6px; margin-bottom: 12px; }
|
||||
button, input, select {
|
||||
border: 1px solid rgba(201, 164, 90, 0.55);
|
||||
background: rgba(20, 17, 12, 0.95);
|
||||
color: #e8ddc4;
|
||||
padding: 8px 10px;
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover, button:focus-visible, button.active { background: rgba(201, 164, 90, 0.22); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
.ghost { background: transparent; }
|
||||
.notice { padding: 9px 12px; border: 1px solid; }
|
||||
.notice.error { color: #ffb3a9; border-color: rgba(255, 90, 70, 0.45); }
|
||||
.notice.success { color: #b9e6af; border-color: rgba(94, 177, 75, 0.45); }
|
||||
.auction-page :deep(.panel-card) { margin-bottom: 12px; }
|
||||
.auction-table { overflow-x: auto; }
|
||||
.table-head, .table-row { display: grid; min-width: 820px; align-items: center; text-align: center; }
|
||||
.resource-table .table-head, .resource-table .table-row { grid-template-columns: 52px 84px 1fr 1fr 1fr 1fr 1fr 150px; }
|
||||
.unique-table .table-head, .unique-table .table-row { grid-template-columns: 52px 2fr 1fr 150px 1fr 110px; }
|
||||
.table-head { border-bottom: 1px solid rgba(232, 221, 196, 0.4); padding: 7px; color: rgba(232, 221, 196, 0.7); }
|
||||
.table-row { width: 100%; border: 0; border-bottom: 1px solid rgba(232, 221, 196, 0.12); background: transparent; }
|
||||
.table-row.selected { background: rgba(201, 164, 90, 0.18); }
|
||||
.table-row > span { padding: 8px 5px; }
|
||||
.bid-form { justify-content: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.bid-form label, .open-form label { display: grid; gap: 5px; }
|
||||
.open-form { align-items: end; gap: 10px; flex-wrap: wrap; }
|
||||
.open-form label { min-width: 110px; flex: 1; }
|
||||
.empty { padding: 14px; text-align: center; color: rgba(232, 221, 196, 0.6); }
|
||||
.log-list { margin: 0; padding-left: 24px; }
|
||||
.log-list li { padding: 4px 0; }
|
||||
.detail-grid { display: grid; grid-template-columns: 130px 1fr 130px 1fr; gap: 1px; background: rgba(232, 221, 196, 0.18); }
|
||||
.detail-grid dt, .detail-grid dd { margin: 0; padding: 9px; background: #11100d; }
|
||||
.detail-grid dt { color: rgba(232, 221, 196, 0.65); }
|
||||
.bid-history { margin-top: 12px; }
|
||||
.bid-entry { justify-content: space-between; gap: 12px; padding: 7px 10px; border-bottom: 1px solid rgba(232, 221, 196, 0.14); }
|
||||
.bid-entry time { color: rgba(232, 221, 196, 0.65); }
|
||||
.me { color: aquamarine; font-weight: 700; }
|
||||
@media (max-width: 720px) {
|
||||
.auction-page { padding: 10px; }
|
||||
.page-header { align-items: flex-start; }
|
||||
.detail-grid { grid-template-columns: 110px 1fr; }
|
||||
.clickable-row:hover,
|
||||
.clickable-row:focus-visible,
|
||||
.clickable-row.selected {
|
||||
background-color: rgb(255 255 255 / 12%);
|
||||
outline: 0;
|
||||
}
|
||||
.no-bid {
|
||||
color: #ccc;
|
||||
}
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.empty-row {
|
||||
min-height: 24px;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.resource-bid-form {
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1fr;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-right: 33.3333%;
|
||||
padding-left: 25%;
|
||||
}
|
||||
.bid-description {
|
||||
text-align: right;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #000;
|
||||
border-radius: 5.25px;
|
||||
padding: 5.25px 10.5px;
|
||||
color: #303030;
|
||||
background: #ddd;
|
||||
font: inherit;
|
||||
}
|
||||
input:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.open-form {
|
||||
min-height: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr 2fr 2fr 1fr;
|
||||
align-items: end;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
padding-right: 8.3333%;
|
||||
padding-left: 16.6667%;
|
||||
}
|
||||
.open-form fieldset,
|
||||
.open-form label {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.open-form legend {
|
||||
padding: 0;
|
||||
}
|
||||
.open-form label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.item-toggle {
|
||||
display: flex;
|
||||
}
|
||||
.item-toggle .legacy-button {
|
||||
flex: 1;
|
||||
}
|
||||
.register-button {
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
.recent-logs {
|
||||
min-height: 24px;
|
||||
}
|
||||
.caller-alias {
|
||||
min-height: 20px;
|
||||
}
|
||||
.caller-alias strong,
|
||||
.is-me {
|
||||
color: aqua;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr 2fr 1fr 2fr 1fr 2fr;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.detail-grid dt,
|
||||
.detail-grid dd {
|
||||
min-width: 0;
|
||||
min-height: 18px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
margin: 0;
|
||||
}
|
||||
.bid-row {
|
||||
min-height: 22px;
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr 3fr;
|
||||
align-items: center;
|
||||
padding: 0 20%;
|
||||
text-align: center;
|
||||
}
|
||||
.bid-header {
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
.bid-row > :nth-child(2) {
|
||||
padding-right: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
.unique-bid-form {
|
||||
min-height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr 1fr;
|
||||
align-items: center;
|
||||
padding: 0 25%;
|
||||
}
|
||||
.unique-bid-form label {
|
||||
text-align: center;
|
||||
}
|
||||
.unique-row {
|
||||
width: 100%;
|
||||
min-height: 22px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 4fr 1fr 2fr 1fr 1fr 2fr;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.unique-header {
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
.unique-row > :last-child {
|
||||
padding-right: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
.auction-notice {
|
||||
min-height: 28px;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.auction-notice.error {
|
||||
background: #842029;
|
||||
}
|
||||
.auction-notice.success {
|
||||
background: #0f5132;
|
||||
}
|
||||
.loading-state {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.bottom-bar {
|
||||
padding-top: 20px;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.legacy-auction-page {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.resource-row {
|
||||
min-height: 43px;
|
||||
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.resource-row .idx {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
.resource-row .host {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
.resource-row .amount {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
.resource-row .highest-bidder {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
.resource-row .highest-bid {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
.resource-row .bid-ratio {
|
||||
grid-column: 4;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
.resource-row .finish-bid {
|
||||
grid-column: 5;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
.resource-row .close-date {
|
||||
grid-column: 6;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
.resource-bid-form {
|
||||
grid-template-columns: 4fr 3fr 2fr;
|
||||
padding-right: 16.6667%;
|
||||
padding-left: 8.3333%;
|
||||
}
|
||||
.open-form {
|
||||
grid-template-columns: 2fr 2.3333fr 2fr 2.3333fr 2.3333fr 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
.detail-grid {
|
||||
grid-template-columns: 2fr 4fr 2fr 4fr;
|
||||
}
|
||||
.bid-row {
|
||||
padding: 0;
|
||||
grid-template-columns: 4fr 4fr 4fr;
|
||||
}
|
||||
.unique-bid-form {
|
||||
padding: 0;
|
||||
grid-template-columns: 5fr 4fr 3fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1028,10 +1125,11 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
|
||||
<BattleGeneralCard
|
||||
v-if="attackerGeneral"
|
||||
:general="attackerGeneral!"
|
||||
v-model:general="attackerGeneral"
|
||||
:options="options!"
|
||||
mode="attacker"
|
||||
title="출병자 설정"
|
||||
:can-import-server="hasGameGeneral"
|
||||
@import="openImportModal(attackerGeneral!)"
|
||||
@save="saveGeneral(attackerGeneral!)"
|
||||
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
||||
@@ -1095,10 +1193,11 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
<BattleGeneralCard
|
||||
v-for="(defender, index) in defenders"
|
||||
:key="defender.id"
|
||||
:general="defender"
|
||||
v-model:general="defenders[index]"
|
||||
: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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -189,9 +189,7 @@ const destroyLetter = async (letterId: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const prevOptions = computed(() =>
|
||||
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
|
||||
);
|
||||
const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
|
||||
|
||||
@@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) =>
|
||||
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
|
||||
|
||||
const canDestroy = (letter: DiplomacyLetter) =>
|
||||
editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
|
||||
editable.value &&
|
||||
letter.state === 'ACTIVATED' &&
|
||||
(data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
|
||||
|
||||
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
|
||||
|
||||
onMounted(() => {
|
||||
loadLetters();
|
||||
void loadLetters();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -270,26 +270,84 @@ onBeforeUnmount(() => {
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(국가 내 공개)</div>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleBold().run()" :class="{ active: briefEditor?.isActive('bold') }">굵게</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleItalic().run()" :class="{ active: briefEditor?.isActive('italic') }">기울임</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleUnderline().run()" :class="{ active: briefEditor?.isActive('underline') }">밑줄</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="briefEditor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: briefEditor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="briefEditor?.chain().focus().toggleItalic().run()"
|
||||
:class="{ active: briefEditor?.isActive('italic') }"
|
||||
>
|
||||
기울임
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="briefEditor?.chain().focus().toggleUnderline().run()"
|
||||
:class="{ active: briefEditor?.isActive('underline') }"
|
||||
>
|
||||
밑줄
|
||||
</button>
|
||||
<button type="button" @click="addLink('brief')">링크</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
||||
<button type="button" @click="uploadTarget = 'brief'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">
|
||||
번호 목록
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
uploadTarget = 'brief';
|
||||
fileInputRef?.click();
|
||||
"
|
||||
:disabled="uploadBusy"
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent v-if="briefEditor" :editor="briefEditor" />
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(외교권자 전용)</div>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleBold().run()" :class="{ active: detailEditor?.isActive('bold') }">굵게</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleItalic().run()" :class="{ active: detailEditor?.isActive('italic') }">기울임</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleUnderline().run()" :class="{ active: detailEditor?.isActive('underline') }">밑줄</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="detailEditor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: detailEditor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="detailEditor?.chain().focus().toggleItalic().run()"
|
||||
:class="{ active: detailEditor?.isActive('italic') }"
|
||||
>
|
||||
기울임
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="detailEditor?.chain().focus().toggleUnderline().run()"
|
||||
:class="{ active: detailEditor?.isActive('underline') }"
|
||||
>
|
||||
밑줄
|
||||
</button>
|
||||
<button type="button" @click="addLink('detail')">링크</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
||||
<button type="button" @click="uploadTarget = 'detail'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">
|
||||
번호 목록
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
uploadTarget = 'detail';
|
||||
fileInputRef?.click();
|
||||
"
|
||||
:disabled="uploadBusy"
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent v-if="detailEditor" :editor="detailEditor" />
|
||||
</div>
|
||||
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
<div v-if="historyOpen[letter.id]" class="history-panel">
|
||||
<template v-if="getPrevLetter(letter)">
|
||||
<p>#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔ {{ getPrevLetter(letter)?.dest.nationName }}</p>
|
||||
<p>
|
||||
#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔
|
||||
{{ getPrevLetter(letter)?.dest.nationName }}
|
||||
</p>
|
||||
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
|
||||
</template>
|
||||
<p v-else class="hint">이전 문서를 찾을 수 없습니다.</p>
|
||||
@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<footer class="letter-actions">
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">
|
||||
승인
|
||||
</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">
|
||||
거부
|
||||
</button>
|
||||
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
|
||||
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
|
||||
<button v-if="canRenew(letter)" type="button" @click="selectedPrevId = letter.id; applyPrevLetter()">추가 문서 작성</button>
|
||||
<button
|
||||
v-if="canRenew(letter)"
|
||||
type="button"
|
||||
@click="
|
||||
selectedPrevId = letter.id;
|
||||
applyPrevLetter();
|
||||
"
|
||||
>
|
||||
추가 문서 작성
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
@@ -565,4 +639,4 @@ onBeforeUnmount(() => {
|
||||
.loading {
|
||||
color: #9aa3b8;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type EmperorDetail = {
|
||||
id: number;
|
||||
serverId: string;
|
||||
winnerNationId: number | null;
|
||||
phase: string;
|
||||
nationCount: string;
|
||||
nationName: string;
|
||||
nationHist: string;
|
||||
genCount: string;
|
||||
personalHist: string;
|
||||
specialHist: string;
|
||||
name: string;
|
||||
type: string;
|
||||
color: string;
|
||||
year: number;
|
||||
month: number;
|
||||
power: number;
|
||||
gennum: number;
|
||||
citynum: number;
|
||||
pop: string;
|
||||
poprate: string;
|
||||
gold: number;
|
||||
rice: number;
|
||||
l12name: string;
|
||||
l11name: string;
|
||||
l10name: string;
|
||||
l9name: string;
|
||||
l8name: string;
|
||||
l7name: string;
|
||||
l6name: string;
|
||||
l5name: string;
|
||||
tiger: string;
|
||||
eagle: string;
|
||||
gen: string;
|
||||
history: string[];
|
||||
};
|
||||
|
||||
type NationEntry = {
|
||||
nation: number;
|
||||
isWinner: boolean;
|
||||
name: string;
|
||||
color: string;
|
||||
type: string;
|
||||
level: number | null;
|
||||
tech: number | null;
|
||||
maxPower: number | null;
|
||||
maxCrew: number | null;
|
||||
maxCities: number[];
|
||||
generals: number[];
|
||||
history: string[];
|
||||
date: string;
|
||||
generalsFull: Array<{ generalNo: number; name: string; lastYearMonth: number | null }>;
|
||||
};
|
||||
|
||||
type DynastyDetailPayload = {
|
||||
emperor: EmperorDetail;
|
||||
nations: NationEntry[];
|
||||
};
|
||||
type DynastyDetailPayload = Awaited<ReturnType<typeof trpc.dynasty.getDetail.query>>;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -72,191 +18,398 @@ const emperorId = computed(() => {
|
||||
const idParam = route.params.id;
|
||||
const raw = Array.isArray(idParam) ? idParam[0] : idParam;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
});
|
||||
|
||||
const dynastyApi = trpc as unknown as {
|
||||
dynasty: {
|
||||
getDetail: {
|
||||
query: (input: { emperorId: number }) => Promise<DynastyDetailPayload>;
|
||||
};
|
||||
};
|
||||
const closePage = async (): Promise<void> => {
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
await router.push('/');
|
||||
};
|
||||
|
||||
const loadDetail = async () => {
|
||||
const loadDetail = async (): Promise<void> => {
|
||||
if (emperorId.value === null) {
|
||||
data.value = null;
|
||||
errorMessage.value = '잘못된 왕조 번호입니다.';
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = await dynastyApi.dynasty.getDetail.query({
|
||||
emperorId: emperorId.value,
|
||||
});
|
||||
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value });
|
||||
} catch (error) {
|
||||
data.value = null;
|
||||
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(emperorId, () => {
|
||||
void loadDetail();
|
||||
});
|
||||
const formatArchiveDate = (value: string): string =>
|
||||
new Intl.DateTimeFormat('sv-SE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date(value));
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail();
|
||||
});
|
||||
watch(emperorId, loadDetail);
|
||||
onMounted(loadDetail);
|
||||
</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" @click="router.push('/dynasty')">목록으로</button>
|
||||
<button class="ghost" @click="loadDetail">데이터 새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="dynasty-detail-container" class="dynasty-page">
|
||||
<table class="legacy-table legacy-bg0 title-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
역 대 왕 조<br />
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
||||
<span class="all-link">
|
||||
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
|
||||
<div v-else-if="!data" class="placeholder">표시할 데이터가 없습니다.</div>
|
||||
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
|
||||
|
||||
<section v-if="data" class="grid gap-4">
|
||||
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: data.emperor.color }" />
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{{ data.emperor.name }}</h2>
|
||||
<p class="text-xs text-zinc-400">{{ data.emperor.phase }} · {{ data.emperor.year }}년 {{ data.emperor.month }}월</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300">
|
||||
<div>국력: {{ data.emperor.power }}</div>
|
||||
<div>도시: {{ data.emperor.citynum }}개</div>
|
||||
<div>장수: {{ data.emperor.gennum }}명</div>
|
||||
<div>인구: {{ data.emperor.pop }}</div>
|
||||
<div>인구율: {{ data.emperor.poprate }}</div>
|
||||
<div>금/쌀: {{ data.emperor.gold }} / {{ data.emperor.rice }}</div>
|
||||
</div>
|
||||
</article>
|
||||
<template v-if="data">
|
||||
<table class="legacy-table legacy-bg0 emperor-table">
|
||||
<colgroup>
|
||||
<col class="short-label" />
|
||||
<col class="picture-column" />
|
||||
<col class="long-value" />
|
||||
<col class="short-label" />
|
||||
<col class="picture-column" />
|
||||
<col class="long-value" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="phase-heading centered" colspan="6">
|
||||
<span class="large-text">{{ data.emperor.phase }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">국 가 수<br />(최종 / 최대)</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.nationCount }}</td>
|
||||
<td class="legacy-bg1 centered">장 수 수<br />(최종 / 최대)</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.genCount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">등 장 국 가</td>
|
||||
<td colspan="5">{{ data.emperor.nationName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">국가별 성향</td>
|
||||
<td colspan="5">{{ data.emperor.nationHist }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">장 수 성 격</td>
|
||||
<td colspan="5">{{ data.emperor.personalHist }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">장 수 특 기</td>
|
||||
<td colspan="5">{{ data.emperor.specialHist }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="nation-heading centered"
|
||||
colspan="6"
|
||||
:style="{
|
||||
backgroundColor: data.emperor.color,
|
||||
color: legacyNationTextColor(data.emperor.color),
|
||||
}"
|
||||
>
|
||||
<span class="large-text">
|
||||
{{ data.emperor.name }} ({{ data.emperor.year }}年 {{ data.emperor.month }}月)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">국 력</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.power }}</td>
|
||||
<td class="legacy-bg1 centered">성 향</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">장 수</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.gennum }}</td>
|
||||
<td class="legacy-bg1 centered">속 령</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.citynum }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">총 인 구</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.pop }}</td>
|
||||
<td class="legacy-bg1 centered">인 구 율</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.poprate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">국 고</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.gold }}</td>
|
||||
<td class="legacy-bg1 centered">병 량</td>
|
||||
<td class="centered" colspan="2">{{ data.emperor.rice }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">황 제</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l12name }}</td>
|
||||
<td class="legacy-bg1 centered">승 상</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l11name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">위 장 군</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l10name }}</td>
|
||||
<td class="legacy-bg1 centered">사 공</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l9name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">표 기 장 군</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l8name }}</td>
|
||||
<td class="legacy-bg1 centered">태 위</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l7name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">거 기 장 군</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l6name }}</td>
|
||||
<td class="legacy-bg1 centered">사 도</td>
|
||||
<td> </td>
|
||||
<td>{{ data.emperor.l5name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">오 호 장 군</td>
|
||||
<td colspan="5">{{ data.emperor.tiger }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">건 안 칠 자</td>
|
||||
<td colspan="5">{{ data.emperor.eagle }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">장 수 들<br />(공헌도 순서)</td>
|
||||
<td colspan="5">{{ data.emperor.gen }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">역 사 기 록</td>
|
||||
<td colspan="5">
|
||||
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<h3 class="text-base font-semibold mb-2">왕조 구성</h3>
|
||||
<div class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
<div>세력 수: {{ data.emperor.nationCount }}</div>
|
||||
<div>장수 수: {{ data.emperor.genCount }}</div>
|
||||
<div>세력 목록: {{ data.emperor.nationName }}</div>
|
||||
<div>세력 유형: {{ data.emperor.nationHist }}</div>
|
||||
</div>
|
||||
</article>
|
||||
<table
|
||||
v-for="nation in data.nations"
|
||||
:key="nation.nation"
|
||||
class="legacy-table legacy-bg2 old-nation-table"
|
||||
>
|
||||
<colgroup>
|
||||
<col class="old-label" />
|
||||
<col class="old-value" />
|
||||
<col class="old-label" />
|
||||
<col class="old-value" />
|
||||
<col class="old-label" />
|
||||
<col class="old-value" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<td
|
||||
class="centered"
|
||||
colspan="6"
|
||||
:style="{ backgroundColor: nation.color, color: legacyNationTextColor(nation.color) }"
|
||||
>
|
||||
【 {{ nation.name }} 】
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="centered">
|
||||
<tr>
|
||||
<td class="legacy-bg1">성향</td>
|
||||
<td>{{ nation.typeName }}</td>
|
||||
<td class="legacy-bg1">-</td>
|
||||
<td>-</td>
|
||||
<td class="legacy-bg1">일자</td>
|
||||
<td>{{ formatArchiveDate(nation.date) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1">최종 작위</td>
|
||||
<td>{{ nation.levelName }}</td>
|
||||
<td class="legacy-bg1">최종 장수 수</td>
|
||||
<td>{{ nation.generals.length }}명</td>
|
||||
<td class="legacy-bg1">기술력</td>
|
||||
<td>{{ nation.tech ?? '' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1">최대 영토 수</td>
|
||||
<td>{{ nation.maxCities.length }}</td>
|
||||
<td class="legacy-bg1">최대 병력 수</td>
|
||||
<td>{{ nation.maxCrew ?? 0 }}명</td>
|
||||
<td class="legacy-bg1">최대 국력</td>
|
||||
<td>{{ nation.maxPower ?? 0 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 top-cell">최대영토</td>
|
||||
<td colspan="5">{{ nation.maxCities.join(', ') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 top-cell">장수명단</td>
|
||||
<td colspan="5">
|
||||
<template v-for="general in nation.generalsFull" :key="general.generalNo">
|
||||
{{ general.name }},
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 top-cell">국가열전</td>
|
||||
<td class="legacy-bg0 nation-history" colspan="5">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-for="(entry, index) in nation.history" :key="index" v-html="formatLog(entry)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<h3 class="text-base font-semibold mb-2">관직</h3>
|
||||
<ul class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
<li v-if="data.emperor.l12name">승상: {{ data.emperor.l12name }}</li>
|
||||
<li v-if="data.emperor.l11name">태사: {{ data.emperor.l11name }}</li>
|
||||
<li v-if="data.emperor.l10name">태부: {{ data.emperor.l10name }}</li>
|
||||
<li v-if="data.emperor.l9name">태위: {{ data.emperor.l9name }}</li>
|
||||
<li v-if="data.emperor.l8name">사도: {{ data.emperor.l8name }}</li>
|
||||
<li v-if="data.emperor.l7name">사공: {{ data.emperor.l7name }}</li>
|
||||
<li v-if="data.emperor.l6name">상서령: {{ data.emperor.l6name }}</li>
|
||||
<li v-if="data.emperor.l5name">상서: {{ data.emperor.l5name }}</li>
|
||||
</ul>
|
||||
<div v-if="!data.emperor.l12name && !data.emperor.l11name" class="text-xs text-zinc-500">등록된 관직 정보가 없습니다.</div>
|
||||
</article>
|
||||
|
||||
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<h3 class="text-base font-semibold mb-2">장수 기록</h3>
|
||||
<div class="text-sm text-zinc-300 space-y-2">
|
||||
<div>
|
||||
<span class="text-xs text-zinc-400">호랑이(전공):</span>
|
||||
<div>{{ data.emperor.tiger || '기록 없음' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-zinc-400">독수리(방화):</span>
|
||||
<div>{{ data.emperor.eagle || '기록 없음' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-zinc-400">헌납 순위:</span>
|
||||
<div>{{ data.emperor.gen || '기록 없음' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<h3 class="text-base font-semibold mb-2">통일 세력 연표</h3>
|
||||
<div v-if="data.emperor.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
|
||||
<ul v-else class="space-y-2 text-sm text-zinc-300">
|
||||
<li v-for="(entry, index) in data.emperor.history" :key="`${data.emperor.id}-hist-${index}`">
|
||||
{{ entry }}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<section class="grid gap-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold">국가 기록</h3>
|
||||
<span class="text-xs text-zinc-400">총 {{ data.nations.length }}개</span>
|
||||
</header>
|
||||
|
||||
<article
|
||||
v-for="nation in data.nations"
|
||||
:key="nation.nation"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: nation.color }" />
|
||||
<div>
|
||||
<h4 class="text-base font-semibold">
|
||||
{{ nation.name }}
|
||||
<span
|
||||
v-if="nation.isWinner"
|
||||
class="ml-2 inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200"
|
||||
>
|
||||
통일 세력
|
||||
</span>
|
||||
</h4>
|
||||
<p class="text-xs text-zinc-400">{{ nation.type || '미상' }} · {{ new Date(nation.date).toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300 mb-3">
|
||||
<div>레벨: {{ nation.level ?? '-' }}</div>
|
||||
<div>기술: {{ nation.tech ?? '-' }}</div>
|
||||
<div>최대 국력: {{ nation.maxPower ?? '-' }}</div>
|
||||
<div>최대 병력: {{ nation.maxCrew ?? '-' }}</div>
|
||||
<div>최대 도시: {{ nation.maxCities.join(', ') || '-' }}</div>
|
||||
<div>장수 수: {{ nation.generals.length }}</div>
|
||||
</div>
|
||||
<div class="text-sm text-zinc-300">
|
||||
<h5 class="text-xs text-zinc-400 mb-1">장수 목록</h5>
|
||||
<div v-if="nation.generalsFull.length === 0" class="text-xs text-zinc-500">기록된 장수가 없습니다.</div>
|
||||
<ul v-else class="flex flex-wrap gap-2">
|
||||
<li
|
||||
v-for="general in nation.generalsFull"
|
||||
:key="general.generalNo"
|
||||
class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{{ general.name }}<span v-if="general.lastYearMonth"> ({{ general.lastYearMonth }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="text-sm text-zinc-300 mt-3">
|
||||
<h5 class="text-xs text-zinc-400 mb-1">국가 연표</h5>
|
||||
<div v-if="nation.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li v-for="(entry, index) in nation.history" :key="`${nation.nation}-hist-${index}`">
|
||||
{{ entry }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
<table class="legacy-table legacy-bg0 footer-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button><br />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
min-width: 1016px;
|
||||
}
|
||||
|
||||
.dynasty-page {
|
||||
width: 1000px;
|
||||
margin: 8px auto 0;
|
||||
color: #fff;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.legacy-table {
|
||||
width: 1000px;
|
||||
border-spacing: 2px;
|
||||
}
|
||||
|
||||
.legacy-table td {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.title-table {
|
||||
height: 47px;
|
||||
}
|
||||
|
||||
.all-link {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.emperor-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.short-label {
|
||||
width: 98px;
|
||||
}
|
||||
|
||||
.picture-column {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.long-value {
|
||||
width: 332px;
|
||||
}
|
||||
|
||||
.phase-heading {
|
||||
background: skyblue;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.large-text {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.old-nation-table {
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.old-label {
|
||||
width: 98px;
|
||||
}
|
||||
|
||||
.old-value {
|
||||
width: 238px;
|
||||
}
|
||||
|
||||
.top-cell {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.nation-history {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.native-button {
|
||||
appearance: auto;
|
||||
box-sizing: border-box;
|
||||
border: 2px outset buttonborder;
|
||||
border-radius: 0;
|
||||
padding: 1px 6px;
|
||||
background: buttonface;
|
||||
color: buttontext;
|
||||
font-family: Arial;
|
||||
font-size: 13.3333px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.legacy-message {
|
||||
margin-top: 10px;
|
||||
border: 1px solid gray;
|
||||
padding: 12px;
|
||||
background: #302016;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legacy-message.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.footer-table {
|
||||
min-height: 50px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type DynastyEntry = {
|
||||
id: number;
|
||||
serverId: string;
|
||||
phase: string;
|
||||
name: string;
|
||||
year: number;
|
||||
month: number;
|
||||
color: string;
|
||||
type: string;
|
||||
power: number;
|
||||
gennum: number;
|
||||
citynum: number;
|
||||
};
|
||||
|
||||
type DynastyListPayload = {
|
||||
entries: DynastyEntry[];
|
||||
};
|
||||
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const entries = ref<DynastyEntry[]>([]);
|
||||
const data = ref<DynastyListPayload | null>(null);
|
||||
|
||||
const loadDynasty = async () => {
|
||||
const closePage = async (): Promise<void> => {
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
await router.push('/');
|
||||
};
|
||||
|
||||
const loadDynasty = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const result = (await trpc.dynasty.getList.query()) as DynastyListPayload;
|
||||
entries.value = result.entries;
|
||||
data.value = await trpc.dynasty.getList.query();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
@@ -37,50 +32,225 @@ const loadDynasty = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDynasty();
|
||||
});
|
||||
onMounted(loadDynasty);
|
||||
</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" @click="loadDynasty">목록 새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="dynasty-list-container" class="dynasty-page">
|
||||
<table class="legacy-table legacy-bg0 title-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
역 대 왕 조<br />
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button><br />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
|
||||
<div v-else-if="entries.length === 0" class="placeholder">표시할 왕조 기록이 없습니다.</div>
|
||||
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
|
||||
|
||||
<section v-else class="grid gap-4">
|
||||
<RouterLink
|
||||
v-for="entry in entries"
|
||||
<template v-if="data">
|
||||
<table v-if="data.current" class="legacy-table legacy-bg0 current-table spaced-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="current-heading" colspan="8">
|
||||
<span class="large-text">현재 ({{ data.current.year }}年 {{ data.current.month }}月)</span>
|
||||
<RouterLink to="/yearbook"><button class="native-button" type="button">역사 보기</button></RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="data.entries.length === 0" class="legacy-message">표시할 왕조 기록이 없습니다.</div>
|
||||
|
||||
<table
|
||||
v-for="entry in data.entries"
|
||||
:key="entry.id"
|
||||
class="block bg-zinc-900 border border-zinc-800 rounded p-4 hover:border-zinc-600 transition"
|
||||
:to="`/dynasty/${entry.id}`"
|
||||
class="legacy-table legacy-bg0 dynasty-table spaced-table"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: entry.color }" />
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{{ entry.name }}</h2>
|
||||
<p class="text-xs text-zinc-400">
|
||||
{{ entry.phase }} · {{ entry.year }}년 {{ entry.month }}월
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-3 gap-2 text-sm text-zinc-300">
|
||||
<div>세력 유형: {{ entry.type || '미상' }}</div>
|
||||
<div>도시: {{ entry.citynum }}개</div>
|
||||
<div>장수: {{ entry.gennum }}명</div>
|
||||
<div class="sm:col-span-3">국력: {{ entry.power }}</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<colgroup>
|
||||
<col class="label-column" />
|
||||
<col class="value-column" />
|
||||
<col class="label-column" />
|
||||
<col class="value-column" />
|
||||
<col class="label-column" />
|
||||
<col class="value-column" />
|
||||
<col class="label-column" />
|
||||
<col class="value-column" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="phase-heading" colspan="8">
|
||||
<span class="large-text">{{ entry.phase }}</span>
|
||||
<RouterLink :to="`/dynasty/${entry.id}`">
|
||||
<button class="native-button" type="button">자세히</button>
|
||||
</RouterLink>
|
||||
<RouterLink v-if="entry.serverId" :to="{ path: '/yearbook', query: { serverID: entry.serverId } }">
|
||||
<button class="native-button" type="button">역사 보기</button>
|
||||
</RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="nation-heading"
|
||||
colspan="8"
|
||||
:style="{ backgroundColor: entry.color, color: legacyNationTextColor(entry.color) }"
|
||||
>
|
||||
<span class="large-text">{{ entry.name }} ({{ entry.year }}年 {{ entry.month }}月)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">국 력</td>
|
||||
<td class="centered">{{ entry.power }}</td>
|
||||
<td class="legacy-bg1 centered">장 수</td>
|
||||
<td class="centered">{{ entry.gennum }}</td>
|
||||
<td class="legacy-bg1 centered">속 령</td>
|
||||
<td class="centered">{{ entry.citynum }}</td>
|
||||
<td class="legacy-bg1 centered">성 향</td>
|
||||
<td class="centered">{{ entry.type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">황 제</td>
|
||||
<td class="centered">{{ entry.l12name }}</td>
|
||||
<td class="legacy-bg1 centered">승 상</td>
|
||||
<td class="centered">{{ entry.l11name }}</td>
|
||||
<td class="legacy-bg1 centered">표 기 장 군</td>
|
||||
<td class="centered">{{ entry.l10name }}</td>
|
||||
<td class="legacy-bg1 centered">사 공</td>
|
||||
<td class="centered">{{ entry.l9name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-bg1 centered">거 기 장 군</td>
|
||||
<td class="centered">{{ entry.l8name }}</td>
|
||||
<td class="legacy-bg1 centered">태 위</td>
|
||||
<td class="centered">{{ entry.l7name }}</td>
|
||||
<td class="legacy-bg1 centered">위 장 군</td>
|
||||
<td class="centered">{{ entry.l6name }}</td>
|
||||
<td class="legacy-bg1 centered">사 도</td>
|
||||
<td class="centered">{{ entry.l5name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<table class="legacy-table legacy-bg0 footer-table spaced-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button><br />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
min-width: 1016px;
|
||||
}
|
||||
|
||||
.dynasty-page {
|
||||
width: 1000px;
|
||||
margin: 8px auto 0;
|
||||
color: #fff;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.legacy-table {
|
||||
width: 1000px;
|
||||
border-spacing: 2px;
|
||||
}
|
||||
|
||||
.legacy-table td {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.spaced-table {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.title-table {
|
||||
height: 47px;
|
||||
}
|
||||
|
||||
.current-table {
|
||||
height: 37px;
|
||||
}
|
||||
|
||||
.dynasty-table {
|
||||
height: 139px;
|
||||
}
|
||||
|
||||
.current-heading,
|
||||
.phase-heading,
|
||||
.nation-heading,
|
||||
.centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.current-heading {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.phase-heading {
|
||||
background: skyblue;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.large-text {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.label-column {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.value-column {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.native-button {
|
||||
appearance: auto;
|
||||
box-sizing: border-box;
|
||||
border: 2px outset buttonborder;
|
||||
border-radius: 0;
|
||||
padding: 1px 6px;
|
||||
background: buttonface;
|
||||
color: buttontext;
|
||||
font-family: Arial;
|
||||
font-size: 13.3333px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.legacy-message {
|
||||
margin-top: 10px;
|
||||
border: 1px solid gray;
|
||||
padding: 12px;
|
||||
background: #302016;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legacy-message.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.footer-table {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const {
|
||||
reservedNationTurns,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxOptions,
|
||||
mailboxGroups,
|
||||
statusLine,
|
||||
realtimeLabel,
|
||||
} = storeToRefs(dashboard);
|
||||
@@ -121,10 +121,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">내 정보&설정</RouterLink>
|
||||
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
|
||||
>토너먼트</RouterLink
|
||||
>
|
||||
@@ -222,22 +223,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>
|
||||
|
||||
@@ -257,22 +262,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">
|
||||
@@ -308,6 +297,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>
|
||||
@@ -402,6 +411,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
void loadData();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -278,19 +278,17 @@ onBeforeUnmount(() => {
|
||||
<div class="panel-header">
|
||||
<h2>국가 방침</h2>
|
||||
<div class="panel-actions">
|
||||
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">
|
||||
수정
|
||||
</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">
|
||||
저장
|
||||
</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">
|
||||
취소
|
||||
</button>
|
||||
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">수정</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">저장</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editingNationMsg" class="editor-toolbar">
|
||||
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
|
||||
<button
|
||||
type="button"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: editor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
@@ -323,21 +321,57 @@ onBeforeUnmount(() => {
|
||||
<div class="panel-card">
|
||||
<h3>자금 예산</h3>
|
||||
<dl>
|
||||
<div><dt>현재</dt><dd>{{ data.gold.toLocaleString() }}</dd></div>
|
||||
<div><dt>단기 수입</dt><dd>{{ data.income.gold.war.toLocaleString() }}</dd></div>
|
||||
<div><dt>세금</dt><dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd></div>
|
||||
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeGold).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div>
|
||||
<dt>현재</dt>
|
||||
<dd>{{ data.gold.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>단기 수입</dt>
|
||||
<dd>{{ data.income.gold.war.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>세금</dt>
|
||||
<dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>수입/지출</dt>
|
||||
<dd>
|
||||
+{{ Math.floor(incomeGold).toLocaleString() }} /
|
||||
{{ Math.floor(-outcomeByBill).toLocaleString() }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>국고 예산</dt>
|
||||
<dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<h3>군량 예산</h3>
|
||||
<dl>
|
||||
<div><dt>현재</dt><dd>{{ data.rice.toLocaleString() }}</dd></div>
|
||||
<div><dt>둔전 수입</dt><dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd></div>
|
||||
<div><dt>세금</dt><dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd></div>
|
||||
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeRice).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div>
|
||||
<dt>현재</dt>
|
||||
<dd>{{ data.rice.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>둔전 수입</dt>
|
||||
<dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>세금</dt>
|
||||
<dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>수입/지출</dt>
|
||||
<dd>
|
||||
+{{ Math.floor(incomeRice).toLocaleString() }} /
|
||||
{{ Math.floor(-outcomeByBill).toLocaleString() }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>국고 예산</dt>
|
||||
<dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
@@ -359,7 +393,13 @@ onBeforeUnmount(() => {
|
||||
<div class="panel-card">
|
||||
<h3>기밀 권한</h3>
|
||||
<div class="input-row">
|
||||
<input v-model.number="policyDraft.secretLimit" type="number" min="1" max="99" :disabled="!editable" />
|
||||
<input
|
||||
v-model.number="policyDraft.secretLimit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
:disabled="!editable"
|
||||
/>
|
||||
<span>년</span>
|
||||
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
|
||||
</div>
|
||||
@@ -376,7 +416,9 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
전쟁 금지
|
||||
</label>
|
||||
<span class="hint">잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)</span>
|
||||
<span class="hint"
|
||||
>잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
@@ -574,4 +616,4 @@ onBeforeUnmount(() => {
|
||||
.loading {
|
||||
color: #9aa3b8;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
|
||||
});
|
||||
|
||||
const listItems = computed(() =>
|
||||
list.value
|
||||
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
|
||||
: []
|
||||
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
|
||||
);
|
||||
|
||||
const info = computed(() => detail.value?.bettingInfo ?? null);
|
||||
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
|
||||
|
||||
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
|
||||
);
|
||||
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
|
||||
|
||||
const pureAmount = computed(() =>
|
||||
(detail.value?.bettingDetail ?? []).reduce(
|
||||
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
|
||||
return result;
|
||||
});
|
||||
|
||||
const usedAmount = computed(() =>
|
||||
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
|
||||
);
|
||||
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
|
||||
|
||||
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
|
||||
|
||||
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
|
||||
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
||||
};
|
||||
|
||||
const parseYearMonth = (yearMonth: number): [number, number] => [
|
||||
Math.floor(yearMonth / 12),
|
||||
(yearMonth % 12) + 1,
|
||||
];
|
||||
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
|
||||
|
||||
const readSelection = (value: string): number[] => {
|
||||
try {
|
||||
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
|
||||
.map((index) => candidates.value[index]?.title ?? '-')
|
||||
.join(', ');
|
||||
|
||||
const isListOpen = (item: BettingListItem): boolean =>
|
||||
!item.finished && listYearMonth.value <= item.closeYearMonth;
|
||||
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
|
||||
|
||||
const isDetailOpen = computed(() =>
|
||||
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
|
||||
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
|
||||
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
|
||||
};
|
||||
|
||||
const expectedMultiplier = (key: string, betAmount: number): string => {
|
||||
if (betAmount <= 0) {
|
||||
return '0.0';
|
||||
const rewardByMatch = computed(() => {
|
||||
const selectCount = info.value?.selectCnt ?? 0;
|
||||
const rewards = new Array<number>(selectCount + 1).fill(0);
|
||||
if (selectCount <= 0) {
|
||||
return rewards;
|
||||
}
|
||||
const amountByMatch = new Map<number, number>();
|
||||
for (const [key, betAmount] of detailRows.value) {
|
||||
const matched = matchCount(key);
|
||||
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
|
||||
}
|
||||
if (selectCount === 1 || info.value?.isExclusive) {
|
||||
rewards[selectCount] = totalAmount.value;
|
||||
return rewards;
|
||||
}
|
||||
|
||||
let remainingReward = totalAmount.value;
|
||||
let accumulatedReward = 0;
|
||||
let nextReward = totalAmount.value;
|
||||
for (let matched = selectCount; matched > 0; matched -= 1) {
|
||||
nextReward /= 2;
|
||||
accumulatedReward += nextReward;
|
||||
if (!amountByMatch.has(matched)) {
|
||||
continue;
|
||||
}
|
||||
rewards[matched] = accumulatedReward;
|
||||
remainingReward -= accumulatedReward;
|
||||
accumulatedReward = 0;
|
||||
}
|
||||
for (let matched = selectCount; matched >= 0; matched -= 1) {
|
||||
if (!amountByMatch.has(matched)) {
|
||||
continue;
|
||||
}
|
||||
rewards[matched] += remainingReward;
|
||||
break;
|
||||
}
|
||||
return rewards;
|
||||
});
|
||||
|
||||
const expectedReward = (key: string): number => {
|
||||
if (!info.value?.finished) {
|
||||
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
||||
return (reward / betAmount).toFixed(1);
|
||||
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
||||
}
|
||||
const matched = matchCount(key);
|
||||
const matchedAmount = detailRows.value
|
||||
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
|
||||
return rewardByMatch.value[matchCount(key)] ?? 0;
|
||||
};
|
||||
|
||||
const rewardDivisor = (key: string, betAmount: number): number =>
|
||||
info.value?.finished
|
||||
? detailRows.value
|
||||
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
|
||||
.reduce((sum, [, value]) => sum + value, 0)
|
||||
: betAmount;
|
||||
|
||||
const expectedMultiplier = (key: string, betAmount: number): string => {
|
||||
const divisor = rewardDivisor(key, betAmount);
|
||||
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
|
||||
};
|
||||
|
||||
const myExpectedReward = (key: string, betAmount: number): string => {
|
||||
const myAmount = myBetMap.value.get(key);
|
||||
if (myAmount === undefined) {
|
||||
return '';
|
||||
}
|
||||
const divisor = rewardDivisor(key, betAmount);
|
||||
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
|
||||
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
|
||||
};
|
||||
|
||||
const loadList = async () => {
|
||||
@@ -295,7 +338,7 @@ onMounted(() => {
|
||||
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
||||
<header class="legacy-top-bar">
|
||||
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
||||
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
|
||||
<div></div>
|
||||
<h1>국가 베팅장</h1>
|
||||
<div></div>
|
||||
<div></div>
|
||||
@@ -309,32 +352,37 @@ onMounted(() => {
|
||||
{{ info.name }}
|
||||
<span v-if="info.finished">(종료)</span>
|
||||
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
||||
({{ parseYearMonth(info.closeYearMonth)[0] }}년
|
||||
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
||||
({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
||||
</span>
|
||||
<span v-else>(베팅 마감)</span>
|
||||
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
||||
</div>
|
||||
|
||||
<div class="betting-candidates">
|
||||
<button
|
||||
<div
|
||||
v-for="(candidate, index) in candidates"
|
||||
:key="`${info.id}-${index}`"
|
||||
type="button"
|
||||
class="betting-candidate"
|
||||
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
|
||||
:disabled="!isDetailOpen"
|
||||
@click="toggleCandidate(index)"
|
||||
class="betting-candidate-cell"
|
||||
>
|
||||
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
||||
<span class="candidate-info">
|
||||
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
||||
</span>
|
||||
<span class="candidate-rate">
|
||||
선택율:
|
||||
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="betting-candidate"
|
||||
:class="{
|
||||
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
|
||||
}"
|
||||
:disabled="!isDetailOpen"
|
||||
@click="toggleCandidate(index)"
|
||||
>
|
||||
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
||||
<span class="candidate-info">
|
||||
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
||||
</span>
|
||||
<span class="candidate-rate">
|
||||
선택율:
|
||||
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
||||
@@ -361,7 +409,7 @@ onMounted(() => {
|
||||
{{ selectionLabel(key) }}
|
||||
</div>
|
||||
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
|
||||
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
|
||||
<div>{{ myExpectedReward(key, betAmount) }}</div>
|
||||
<div>{{ expectedMultiplier(key, betAmount) }}배</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,8 +430,7 @@ onMounted(() => {
|
||||
{{ item.name }}
|
||||
<span v-if="item.finished">(종료)</span>
|
||||
<span v-else-if="isListOpen(item)">
|
||||
({{ parseYearMonth(item.closeYearMonth)[0] }}년
|
||||
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
||||
({{ parseYearMonth(item.closeYearMonth)[0] }}년 {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
||||
</span>
|
||||
<span v-else>(베팅 마감)</span>
|
||||
</button>
|
||||
@@ -400,12 +447,11 @@ onMounted(() => {
|
||||
.nation-betting-page {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.5;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -458,19 +504,32 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-title {
|
||||
min-height: 22px;
|
||||
min-height: 21px;
|
||||
text-align: center;
|
||||
line-height: 22px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.betting-candidates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: -3.5px;
|
||||
margin-right: -1.75px;
|
||||
margin-left: -1.75px;
|
||||
}
|
||||
|
||||
.betting-candidate-cell {
|
||||
flex: 0 0 auto;
|
||||
width: 33.33333333%;
|
||||
max-width: 100%;
|
||||
padding-right: 1.75px;
|
||||
padding-left: 1.75px;
|
||||
margin-top: 3.5px;
|
||||
}
|
||||
|
||||
.betting-candidate {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 143px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 1px solid gray;
|
||||
@@ -530,7 +589,7 @@ onMounted(() => {
|
||||
.betting-form input {
|
||||
grid-column: span 4;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
height: 35.5px;
|
||||
border: 1px solid #777;
|
||||
background: #ddd;
|
||||
color: #303030;
|
||||
@@ -538,10 +597,11 @@ onMounted(() => {
|
||||
|
||||
.betting-form button {
|
||||
grid-column: span 2;
|
||||
height: 35.5px;
|
||||
}
|
||||
|
||||
.payout-table {
|
||||
margin-top: 6px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.payout-row {
|
||||
@@ -551,7 +611,7 @@ onMounted(() => {
|
||||
|
||||
.payout-row > div {
|
||||
min-width: 0;
|
||||
padding: 2px 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.payout-row > div:not(:first-child) {
|
||||
@@ -572,7 +632,7 @@ onMounted(() => {
|
||||
|
||||
.betting-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
margin: 0.25em;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -595,6 +655,7 @@ onMounted(() => {
|
||||
|
||||
.betting-footer .legacy-nav-button {
|
||||
width: 90px;
|
||||
height: 35.5px;
|
||||
}
|
||||
|
||||
.betting-notice,
|
||||
@@ -602,6 +663,15 @@ onMounted(() => {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.betting-notice {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
width: min(320px, calc(100vw - 16px));
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.betting-notice.error {
|
||||
border: 1px solid #9b4848;
|
||||
color: #ffd0d0;
|
||||
@@ -617,8 +687,9 @@ onMounted(() => {
|
||||
width: 1000px;
|
||||
}
|
||||
|
||||
.betting-candidates {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
.betting-candidate-cell {
|
||||
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
|
||||
width: 16.66666667%;
|
||||
}
|
||||
|
||||
.betting-form {
|
||||
|
||||
@@ -1,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"><비활성화 항목들></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>
|
||||
|
||||
@@ -134,7 +134,7 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
void loadData();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -168,7 +168,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="editing" class="editor-toolbar">
|
||||
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
|
||||
<button
|
||||
type="button"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: editor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import MapViewer from '../components/main/MapViewer.vue';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
@@ -25,12 +25,18 @@ type HistoryData = {
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const range = ref<YearbookRange | null>(null);
|
||||
const mapLayout = ref<MapLayout | null>(null);
|
||||
const history = ref<HistoryData | null>(null);
|
||||
const selectedYearMonth = ref<number | null>(null);
|
||||
const serverID = computed(() => {
|
||||
const value = route.query.serverID;
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
||||
});
|
||||
|
||||
const parseYearMonth = (value: number): { year: number; month: number } => ({
|
||||
year: Math.floor(value / 12),
|
||||
@@ -66,7 +72,7 @@ const loadHistory = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const { year, month } = parseYearMonth(selectedYearMonth.value);
|
||||
const result = await trpc.yearbook.getHistory.query({ year, month });
|
||||
const result = await trpc.yearbook.getHistory.query({ year, month, serverID: serverID.value });
|
||||
if ('data' in result) {
|
||||
history.value = result.data;
|
||||
}
|
||||
@@ -96,7 +102,7 @@ onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [loadedRange, loadedLayout] = await Promise.all([
|
||||
trpc.yearbook.getRange.query(),
|
||||
trpc.yearbook.getRange.query(serverID.value ? { serverID: serverID.value } : undefined),
|
||||
trpc.public.getMapLayout.query(),
|
||||
]);
|
||||
range.value = loadedRange;
|
||||
|
||||
@@ -817,6 +817,8 @@ export const adminRouter = router({
|
||||
profileName: profile.profileName,
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,6 +26,8 @@ export type LobbyProfileStatus = {
|
||||
runtime: {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
auctionRunning: boolean;
|
||||
battleSimRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
};
|
||||
korName: string;
|
||||
@@ -69,7 +71,16 @@ 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;
|
||||
auctionRunning: boolean;
|
||||
battleSimRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
}
|
||||
>
|
||||
): LobbyProfileStatus {
|
||||
const meta = row.meta;
|
||||
return {
|
||||
@@ -81,6 +92,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
runtime: runtimeMap.get(row.profileName) ?? {
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface GatewayOrchestratorOptions {
|
||||
export interface ProfileRuntimeState {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
auctionRunning: boolean;
|
||||
battleSimRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
}
|
||||
|
||||
@@ -66,13 +68,24 @@ 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.auctionRunning &&
|
||||
runtime.battleSimRunning &&
|
||||
runtime.tournamentRunning
|
||||
),
|
||||
shouldStop: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldStart: false,
|
||||
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
|
||||
shouldStop:
|
||||
runtime.apiRunning ||
|
||||
runtime.daemonRunning ||
|
||||
runtime.auctionRunning ||
|
||||
runtime.battleSimRunning ||
|
||||
runtime.tournamentRunning,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -273,8 +286,21 @@ 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' | 'auction' | 'battle-sim' | 'tournament'
|
||||
): string =>
|
||||
`sammo:${profileName}:${
|
||||
role === 'api'
|
||||
? 'game-api'
|
||||
: role === 'daemon'
|
||||
? 'turn-daemon'
|
||||
: role === 'auction'
|
||||
? 'auction-worker'
|
||||
: 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 +311,15 @@ 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> };
|
||||
auction: { 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 auctionName = buildProcessName(profile.profileName, 'auction');
|
||||
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');
|
||||
@@ -300,6 +330,7 @@ export const buildProcessDefinitions = (
|
||||
...baseEnv,
|
||||
PROFILE: profile.profile,
|
||||
SCENARIO: profile.scenario,
|
||||
GAME_PROFILE_NAME: profile.profileName,
|
||||
GAME_API_PORT: String(profile.apiPort),
|
||||
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
|
||||
GAME_API_EVENTS_PATH: `/${profile.profile}/api/events`,
|
||||
@@ -327,6 +358,24 @@ export const buildProcessDefinitions = (
|
||||
cwd: daemonCwd,
|
||||
env: daemonEnv,
|
||||
},
|
||||
auction: {
|
||||
name: auctionName,
|
||||
script: apiScript,
|
||||
cwd: apiCwd,
|
||||
env: {
|
||||
...apiEnv,
|
||||
GAME_API_ROLE: 'auction-worker',
|
||||
},
|
||||
},
|
||||
battleSim: {
|
||||
name: battleSimName,
|
||||
script: apiScript,
|
||||
cwd: apiCwd,
|
||||
env: {
|
||||
...apiEnv,
|
||||
GAME_API_ROLE: 'battle-sim-worker',
|
||||
},
|
||||
},
|
||||
tournament: {
|
||||
name: tournamentName,
|
||||
script: apiScript,
|
||||
@@ -376,11 +425,15 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
||||
profileNames.map((profileName) => {
|
||||
const apiName = buildProcessName(profileName, 'api');
|
||||
const daemonName = buildProcessName(profileName, 'daemon');
|
||||
const auctionName = buildProcessName(profileName, 'auction');
|
||||
const battleSimName = buildProcessName(profileName, 'battle-sim');
|
||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||
return {
|
||||
profileName,
|
||||
apiRunning: processNames.get(apiName) ?? false,
|
||||
daemonRunning: processNames.get(daemonName) ?? false,
|
||||
auctionRunning: processNames.get(auctionName) ?? false,
|
||||
battleSimRunning: processNames.get(battleSimName) ?? false,
|
||||
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||
};
|
||||
});
|
||||
@@ -981,6 +1034,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
try {
|
||||
await this.processManager.start(definitions.api);
|
||||
await this.processManager.start(definitions.daemon);
|
||||
await this.processManager.start(definitions.auction);
|
||||
await this.processManager.start(definitions.battleSim);
|
||||
await this.processManager.start(definitions.tournament);
|
||||
await this.repository.updateLastError(profile.profileName, null);
|
||||
return true;
|
||||
@@ -996,10 +1051,12 @@ 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 auctionName = buildProcessName(profile.profileName, 'auction');
|
||||
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, auctionName, battleSimName, tournamentName]) {
|
||||
if (!existingNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ const createHarness = (
|
||||
? [
|
||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
||||
{ name: 'sammo:che:2:auction-worker', status: 'online' },
|
||||
{ name: 'sammo:che:2:battle-sim-worker', status: 'online' },
|
||||
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
||||
]
|
||||
: [],
|
||||
@@ -138,7 +140,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 +149,14 @@ 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:auction-worker',
|
||||
'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 +165,15 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
expect(harness.stopped).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
'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:auction-worker',
|
||||
'sammo:che:2:battle-sim-worker',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
@@ -190,6 +198,8 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
'sammo:che:2:battle-sim-worker',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
@@ -203,7 +213,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 +221,15 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
expect(harness.stopped).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
'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:auction-worker',
|
||||
'sammo:che:2:battle-sim-worker',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.completions).toEqual(['FAILED']);
|
||||
|
||||
@@ -29,6 +29,8 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('RUNNING', {
|
||||
apiRunning: true,
|
||||
daemonRunning: false,
|
||||
auctionRunning: true,
|
||||
battleSimRunning: true,
|
||||
tournamentRunning: true,
|
||||
})
|
||||
).toEqual({ shouldStart: true, shouldStop: false });
|
||||
@@ -39,6 +41,8 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('PREOPEN', {
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
})
|
||||
).toEqual({ shouldStart: true, shouldStop: false });
|
||||
@@ -49,16 +53,32 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('RUNNING', {
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: true,
|
||||
battleSimRunning: true,
|
||||
tournamentRunning: true,
|
||||
})
|
||||
).toEqual({ shouldStart: false, shouldStop: false });
|
||||
});
|
||||
|
||||
it('restarts a running profile when only the auction worker is missing', () => {
|
||||
expect(
|
||||
planProfileReconcile('RUNNING', {
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: true,
|
||||
tournamentRunning: true,
|
||||
})
|
||||
).toEqual({ shouldStart: true, shouldStop: false });
|
||||
});
|
||||
|
||||
it('stops processes for non-running profiles', () => {
|
||||
expect(
|
||||
planProfileReconcile('STOPPED', {
|
||||
apiRunning: false,
|
||||
daemonRunning: true,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
})
|
||||
).toEqual({ shouldStart: false, shouldStop: true });
|
||||
@@ -69,6 +89,8 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('RESERVED', {
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
})
|
||||
).toEqual({ shouldStart: false, shouldStop: false });
|
||||
@@ -89,12 +111,23 @@ describe('buildProcessDefinitions', () => {
|
||||
expect(definitions.api.cwd).toBe(path.join(buildWorkspace, 'app', 'game-api'));
|
||||
expect(definitions.api.script).toBe(path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'));
|
||||
expect(definitions.api.env).toMatchObject({
|
||||
GAME_PROFILE_NAME: 'che:2',
|
||||
GAME_TRPC_PATH: '/che/api/trpc',
|
||||
GAME_API_EVENTS_PATH: '/che/api/events',
|
||||
GAME_UPLOAD_PATH: '/che/api/uploads',
|
||||
});
|
||||
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.auction).toMatchObject({
|
||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
||||
env: { GAME_API_ROLE: 'auction-worker' },
|
||||
});
|
||||
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 +140,8 @@ 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.auction.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: 'general-icon-lifecycle.spec.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 720_000,
|
||||
globalTimeout: 780_000,
|
||||
expect: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/general-icon-lifecycle'),
|
||||
use: {
|
||||
baseURL: process.env.SAMMO_LIFECYCLE_BASE_URL ?? 'http://127.0.0.1:15140',
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
webServer: {
|
||||
command: 'node e2e/prefix-proxy.mjs',
|
||||
cwd: resolve(repositoryRoot, 'app/gateway-frontend'),
|
||||
url: 'http://127.0.0.1:15140/gateway/',
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { expect, test, type Browser, type Page, type TestInfo } from '@playwright/test';
|
||||
|
||||
const requiredEnv = (name: string): string => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const readPassword = async (account: 'admin'): Promise<string> => {
|
||||
const root = requiredEnv('SAMMO_LIFECYCLE_SECRET_ROOT');
|
||||
return (await readFile(`${root}/${account}_password`, 'utf8')).trim();
|
||||
};
|
||||
|
||||
const login = async (page: Page, username: string, password: string): Promise<void> => {
|
||||
await page.goto('/gateway/');
|
||||
await page.getByLabel('계정명').fill(username);
|
||||
await page.getByLabel('비밀번호').fill(password);
|
||||
await page.getByRole('button', { name: '로그인', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
};
|
||||
|
||||
const hweRow = (page: Page) => page.locator('tbody tr').filter({ hasText: /^hwe섭/ });
|
||||
|
||||
const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise<void> => {
|
||||
await page.goto('/gateway/admin/server-operations');
|
||||
await page.getByTestId('profile-select').selectOption('hwe:2');
|
||||
await page.getByTestId('source-commit').check();
|
||||
await page.getByTestId('source-ref').fill(sourceCommit);
|
||||
await page.getByTestId('load-scenarios').click();
|
||||
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
|
||||
await page.getByTestId('scenario-select').selectOption(scenarioId);
|
||||
|
||||
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
|
||||
const previousLatestOperation = await latestOperation.textContent();
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => latestOperation.textContent(), { timeout: 15_000 })
|
||||
.not.toBe(previousLatestOperation);
|
||||
await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 });
|
||||
await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', {
|
||||
timeout: 300_000,
|
||||
});
|
||||
const profileStatus = page.getByTestId('selected-profile-status');
|
||||
await expect(profileStatus.locator(':scope > div').nth(0)).toContainText('RUNNING', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(profileStatus.locator(':scope > div').nth(1)).toContainText('SUCCEEDED');
|
||||
await expect(profileStatus.locator('.text-emerald-400')).toHaveCount(3, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(profileStatus.locator('..').locator('.text-red-400')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(async () => {
|
||||
const response = await fetch(
|
||||
'/hwe/api/trpc/lobby.info,public.getMapLayout,public.getCachedMap?batch=1&input=%7B%7D'
|
||||
);
|
||||
return response.status;
|
||||
}),
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
.toBe(200);
|
||||
};
|
||||
|
||||
type PossessCandidateBatch = Array<{
|
||||
result?: {
|
||||
data?: Array<{ picture?: string | null }>;
|
||||
};
|
||||
}>;
|
||||
|
||||
const inspectScenarioIcons = async (
|
||||
browser: Browser,
|
||||
testInfo: TestInfo,
|
||||
scenarioId: string,
|
||||
expectedDirectory: string
|
||||
): Promise<string[]> => {
|
||||
const context = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
colorScheme: 'dark',
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await login(page, 'guiadmin', await readPassword('admin'));
|
||||
const row = hweRow(page);
|
||||
await expect(row.getByRole('button', { name: '장수생성' })).toBeEnabled({ timeout: 60_000 });
|
||||
await row.getByRole('button').click();
|
||||
await expect(page).toHaveURL(/\/hwe\/join$/);
|
||||
const candidatesResponse = page.waitForResponse(
|
||||
(candidate) =>
|
||||
candidate.url().includes('/hwe/api/trpc/join.listPossessCandidates') && candidate.status() === 200
|
||||
);
|
||||
await page.getByRole('button', { name: 'NPC 빙의' }).click();
|
||||
const payload = (await (await candidatesResponse).json()) as PossessCandidateBatch;
|
||||
const iconPaths = payload
|
||||
.flatMap((entry) => entry.result?.data ?? [])
|
||||
.map((candidate) => candidate.picture)
|
||||
.filter(
|
||||
(picture): picture is string =>
|
||||
typeof picture === 'string' && picture.startsWith(`${expectedDirectory}/`)
|
||||
);
|
||||
expect(iconPaths.length).toBeGreaterThan(0);
|
||||
|
||||
const imageRoot = requiredEnv('SAMMO_IMAGE_ROOT');
|
||||
for (const iconPath of iconPaths) {
|
||||
await access(path.join(imageRoot, 'icons', iconPath));
|
||||
}
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`scenario-${scenarioId}-npc-icons.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await context.close();
|
||||
return iconPaths;
|
||||
};
|
||||
|
||||
test('Chromium resets numeric and null-picture scenarios to repository-backed name paths', async ({
|
||||
browser,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(720_000);
|
||||
const sourceCommit = requiredEnv('SAMMO_LIFECYCLE_SOURCE_COMMIT');
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await login(page, 'guiadmin', await readPassword('admin'));
|
||||
|
||||
await resetScenario(page, '1010', sourceCommit);
|
||||
const numericScenarioIcons = await inspectScenarioIcons(browser, testInfo, '1010', '장수');
|
||||
expect(numericScenarioIcons.every((iconPath) => iconPath.startsWith('장수/'))).toBe(true);
|
||||
|
||||
await resetScenario(page, '2220', sourceCommit);
|
||||
const nullScenarioIcons = await inspectScenarioIcons(browser, testInfo, '2220', '장수');
|
||||
expect(nullScenarioIcons.every((iconPath) => iconPath.startsWith('장수/'))).toBe(true);
|
||||
|
||||
await resetScenario(page, '2140', sourceCommit);
|
||||
const themedScenarioIcons = await inspectScenarioIcons(browser, testInfo, '2140', '걸그룹');
|
||||
expect(themedScenarioIcons.every((iconPath) => iconPath.startsWith('걸그룹/'))).toBe(true);
|
||||
});
|
||||
@@ -38,6 +38,8 @@ const profile = (runtimeRunning: boolean) => ({
|
||||
profileName: 'che:2',
|
||||
apiRunning: runtimeRunning,
|
||||
daemonRunning: runtimeRunning,
|
||||
auctionRunning: runtimeRunning,
|
||||
battleSimRunning: runtimeRunning,
|
||||
tournamentRunning: runtimeRunning,
|
||||
},
|
||||
});
|
||||
@@ -212,7 +214,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('starts and stops both runtime roles through the operation controls', async ({ page }) => {
|
||||
test('starts and stops all runtime roles through the operation controls', async ({ page }) => {
|
||||
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
||||
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -70,6 +70,8 @@ type AdminProfile = {
|
||||
runtime: {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
auctionRunning: boolean;
|
||||
battleSimRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
};
|
||||
buildCommitSha?: string;
|
||||
@@ -1352,7 +1354,9 @@ 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' }} / AUCTION:
|
||||
{{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / BATTLE SIM:
|
||||
{{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
|
||||
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,13 @@ type Profile = {
|
||||
buildWorkspace?: string;
|
||||
buildError?: string;
|
||||
lastError?: string;
|
||||
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
|
||||
runtime: {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
auctionRunning: boolean;
|
||||
battleSimRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type Scenario = {
|
||||
@@ -377,6 +383,20 @@ onBeforeUnmount(() => {
|
||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">Auction worker</div>
|
||||
<div :class="selectedProfile.runtime.auctionRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
||||
{{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">Battle sim worker</div>
|
||||
<div
|
||||
:class="selectedProfile.runtime.battleSimRunning ? 'text-emerald-400' : 'text-zinc-500'"
|
||||
>
|
||||
{{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">Tournament worker</div>
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,18 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한
|
||||
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
||||
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
||||
비교한다.
|
||||
|
||||
2026-07-26부터 canonical snapshot은 관찰 장수의 `rank_data`도 비교한다.
|
||||
ref의 `RankColumn` 37종만 의미 행으로 정규화하며, ref에서 자연 `general`
|
||||
column인 경험·공헌·숙련을 위해 core가 보유한 7개 mirror row는 비교에서
|
||||
제외한다. 화계 fixture는 같은 초기 `firenum`에서 성공 명령 뒤 양쪽이
|
||||
동일하게 1 증가하는지 확인한다. 은퇴 fixture는 37종 전부를 서로 다른
|
||||
비영 값으로 채운 뒤 양쪽이 전부 0으로 만드는지 확인한다. 이 검증으로
|
||||
일반 명령 snapshot에서 누락됐던 명장일람 누적치와 은퇴 후 메모리→DB
|
||||
재저장 경로를 관찰한다.
|
||||
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
|
||||
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
|
||||
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
|
||||
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
|
||||
않아 동적 호환 판정에서 제외한다.
|
||||
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
|
||||
|
||||
@@ -41,6 +41,30 @@ Notes:
|
||||
`affinity, name, picture, nationName, city, leadership, strength, intel,
|
||||
officerLevel, birth, death, ego, char, text`.
|
||||
|
||||
## General Icon Paths
|
||||
|
||||
Scenario general rows store an explicit path relative to `/image/icons`.
|
||||
New shared portraits use `장수/<general-name>.<extension>`; scenario-specific
|
||||
portraits keep their existing directory such as `걸그룹/` or `롤시나리오/`.
|
||||
This makes active and delayed generals persist the same path without requiring
|
||||
the legacy image service's runtime `list.json` lookup.
|
||||
|
||||
`resources/general-icons.json` records the canonical general name, current
|
||||
path, retained numeric legacy aliases, and source image. The numeric files in
|
||||
the image repository remain compatibility aliases for deployed legacy
|
||||
versions. Run the following from a core2026 checkout whenever scenarios or
|
||||
portrait assets change:
|
||||
|
||||
```sh
|
||||
pnpm manage:general-icons --image-root /path/to/image --write
|
||||
pnpm manage:general-icons --image-root /path/to/image
|
||||
```
|
||||
|
||||
The first command synchronizes canonical aliases and scenario paths. The second
|
||||
is a read-only drift and file-content check. Missing source images stay listed
|
||||
under the catalog's `unresolved` field and resolve to `default.jpg` when they
|
||||
were an explicit numeric reference.
|
||||
|
||||
## How Scenario Chooses Commands and Effects
|
||||
|
||||
Scenario config influences runtime rules via `GameConst` and `ScenarioEffect`:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user