Merge branch 'main' into feature/dynasty-list-parity
# Conflicts: # tools/frontend-legacy-parity/playwright.config.mjs
This commit is contained in:
@@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes
|
|||||||
import { processBattleSimJob } from './processor.js';
|
import { processBattleSimJob } from './processor.js';
|
||||||
|
|
||||||
export class InMemoryBattleSimTransport {
|
export class InMemoryBattleSimTransport {
|
||||||
private readonly results = new Map<string, BattleSimResultPayload>();
|
private readonly results = new Map<string, { requesterUserId: string; payload: BattleSimResultPayload }>();
|
||||||
|
|
||||||
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
|
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
|
||||||
const jobId = crypto.randomUUID();
|
const jobId = crypto.randomUUID();
|
||||||
const result = processBattleSimJob(payload);
|
const result = processBattleSimJob(payload);
|
||||||
this.results.set(jobId, result);
|
this.results.set(jobId, { requesterUserId, payload: result });
|
||||||
return { status: 'completed', jobId, payload: result };
|
return { status: 'completed', jobId, payload: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
return this.results.get(jobId) ?? null;
|
const result = this.results.get(jobId);
|
||||||
|
if (!result || result.requesterUserId !== requesterUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return result.payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,16 +44,16 @@ export class RedisBattleSimTransport {
|
|||||||
this.resultTtlSeconds = options.resultTtlSeconds;
|
this.resultTtlSeconds = options.resultTtlSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildResultKey(jobId: string): string {
|
private buildResultKey(jobId: string, requesterUserId: string): string {
|
||||||
return `${this.keys.resultKeyPrefix}${jobId}`;
|
return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildNotifyKey(jobId: string): string {
|
private buildNotifyKey(jobId: string, requesterUserId: string): string {
|
||||||
return `${this.keys.notifyKeyPrefix}${jobId}`;
|
return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
const raw = await this.client.get(this.buildResultKey(jobId));
|
const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId));
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,44 +64,49 @@ export class RedisBattleSimTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
|
private async waitForResult(
|
||||||
const existing = await this.readResult(jobId);
|
jobId: string,
|
||||||
|
requesterUserId: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<BattleSimResultPayload | null> {
|
||||||
|
const existing = await this.readResult(jobId, requesterUserId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyKey = this.buildNotifyKey(jobId);
|
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||||
const timeoutSec = toTimeoutSeconds(timeoutMs);
|
const timeoutSec = toTimeoutSeconds(timeoutMs);
|
||||||
const signal = await this.client.blPop(notifyKey, timeoutSec);
|
const signal = await this.client.blPop(notifyKey, timeoutSec);
|
||||||
if (!parseBlPopValue(signal)) {
|
if (!parseBlPopValue(signal)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return this.readResult(jobId);
|
return this.readResult(jobId, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
|
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
|
||||||
const jobId = crypto.randomUUID();
|
const jobId = crypto.randomUUID();
|
||||||
const job = {
|
const job = {
|
||||||
jobId,
|
jobId,
|
||||||
|
requesterUserId,
|
||||||
requestedAt: new Date().toISOString(),
|
requestedAt: new Date().toISOString(),
|
||||||
payload,
|
payload,
|
||||||
};
|
};
|
||||||
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
|
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
|
||||||
|
|
||||||
const result = await this.waitForResult(jobId, this.requestTimeoutMs);
|
const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs);
|
||||||
if (result) {
|
if (result) {
|
||||||
return { status: 'completed', jobId, payload: result };
|
return { status: 'completed', jobId, payload: result };
|
||||||
}
|
}
|
||||||
return { status: 'queued', jobId };
|
return { status: 'queued', jobId };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
return this.readResult(jobId);
|
return this.readResult(jobId, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
|
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
|
||||||
const resultKey = this.buildResultKey(jobId);
|
const resultKey = this.buildResultKey(jobId, requesterUserId);
|
||||||
const notifyKey = this.buildNotifyKey(jobId);
|
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||||
await this.client.set(resultKey, JSON.stringify(payload), {
|
await this.client.set(resultKey, JSON.stringify(payload), {
|
||||||
EX: this.resultTtlSeconds,
|
EX: this.resultTtlSeconds,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
||||||
|
|
||||||
export interface BattleSimTransport {
|
export interface BattleSimTransport {
|
||||||
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
|
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
|
||||||
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
|
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
|
|||||||
|
|
||||||
export interface BattleSimJob {
|
export interface BattleSimJob {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
requesterUserId: string;
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
payload: BattleSimJobPayload;
|
payload: BattleSimJobPayload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
|
|||||||
return result.element ?? null;
|
return result.element ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runBattleSimWorker = async (): Promise<void> => {
|
export interface BattleSimWorkerOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise<void> => {
|
||||||
const config = resolveGameApiConfigFromEnv();
|
const config = resolveGameApiConfigFromEnv();
|
||||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
await redis.connect();
|
await redis.connect();
|
||||||
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
|
|||||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleExit = async () => {
|
let stopped = options.signal?.aborted ?? false;
|
||||||
await redis.disconnect();
|
const handleExit = () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
const handleAbort = () => {
|
||||||
|
stopped = true;
|
||||||
};
|
};
|
||||||
process.on('SIGINT', handleExit);
|
process.on('SIGINT', handleExit);
|
||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
|
options.signal?.addEventListener('abort', handleAbort, { once: true });
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
const item = await redis.client.blPop(keys.queueKey, 0);
|
while (!stopped) {
|
||||||
const raw = parseBlPopValue(item);
|
// A finite block lets SIGTERM and test AbortSignal stop the worker without
|
||||||
if (!raw) {
|
// leaving a Redis operation or a detached lifecycle process behind.
|
||||||
continue;
|
const item = await redis.client.blPop(keys.queueKey, 1);
|
||||||
}
|
const raw = parseBlPopValue(item);
|
||||||
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let job: BattleSimJob | null = null;
|
let job: BattleSimJob | null = null;
|
||||||
try {
|
try {
|
||||||
job = JSON.parse(raw) as BattleSimJob;
|
job = JSON.parse(raw) as BattleSimJob;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = processBattleSimJob(job.payload);
|
const result = processBattleSimJob(job.payload);
|
||||||
await transport.pushResult(job.jobId, result);
|
await transport.pushResult(job.jobId, job.requesterUserId, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
||||||
await transport.pushResult(job.jobId, {
|
await transport.pushResult(job.jobId, job.requesterUserId, {
|
||||||
result: false,
|
result: false,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
process.off('SIGINT', handleExit);
|
||||||
|
process.off('SIGTERM', handleExit);
|
||||||
|
options.signal?.removeEventListener('abort', handleAbort);
|
||||||
|
await redis.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export * from './auction/types.js';
|
|||||||
export * from './auction/keys.js';
|
export * from './auction/keys.js';
|
||||||
export * from './auction/scheduler.js';
|
export * from './auction/scheduler.js';
|
||||||
export * from './auction/worker.js';
|
export * from './auction/worker.js';
|
||||||
|
export * from './tournament/keys.js';
|
||||||
|
export * from './tournament/store.js';
|
||||||
|
export * from './tournament/types.js';
|
||||||
export * from './tournament/worker.js';
|
export * from './tournament/worker.js';
|
||||||
|
|
||||||
// Types for TRPC consumer
|
// Types for TRPC consumer
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ export const resolveNationInfo = async (
|
|||||||
|
|
||||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||||
const nation = await resolveNationInfo(db, general.nationId);
|
const nation = await resolveNationInfo(db, general.nationId);
|
||||||
|
const picture = general.picture?.trim() || 'default.jpg';
|
||||||
return {
|
return {
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: '',
|
icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { getDexLevel } from '@sammo-ts/logic';
|
import { getDexLevel } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { authedProcedure, procedure, router } from '../../trpc.js';
|
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
||||||
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => {
|
||||||
|
const userId = auth?.user.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
|
const resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
|
||||||
const expLevel = meta.explevel ?? meta.expLevel;
|
const expLevel = meta.explevel ?? meta.expLevel;
|
||||||
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
|
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
|
||||||
@@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const battleRouter = router({
|
export const battleRouter = router({
|
||||||
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
if (!worldState) {
|
if (!worldState) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -58,10 +66,10 @@ export const battleRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
||||||
return ctx.battleSim.simulate(payload);
|
return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth));
|
||||||
}),
|
}),
|
||||||
getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
||||||
const result = await ctx.battleSim.getSimulationResult(input.jobId);
|
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return { status: 'queued', jobId: input.jobId };
|
return { status: 'queued', jobId: input.jobId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveUserSettings = (meta: Record<string, unknown>) => {
|
const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||||
const settings = asRecord(meta.userSettings);
|
// The legacy general columns are persisted at the top level of General.meta.
|
||||||
const mysetRaw = settings.myset;
|
// Keep reading the short-lived nested shape for installations that ran the
|
||||||
|
// initial rewrite implementation before this compatibility fix.
|
||||||
|
const nestedSettings = asRecord(meta.userSettings);
|
||||||
|
const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key];
|
||||||
|
const mysetRaw = readSetting('myset');
|
||||||
const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null;
|
const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tnmt: readNumber(settings.tnmt, 1),
|
tnmt: readNumber(readSetting('tnmt'), 1),
|
||||||
defence_train: readNumber(settings.defence_train, 80),
|
defence_train: readNumber(readSetting('defence_train'), 80),
|
||||||
use_treatment: readNumber(settings.use_treatment, 10),
|
use_treatment: readNumber(readSetting('use_treatment'), 10),
|
||||||
use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1),
|
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
|
||||||
myset,
|
myset,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -262,29 +266,6 @@ export const generalRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaRecord = asRecord(general.meta);
|
|
||||||
const prevSettings = asRecord(metaRecord.userSettings);
|
|
||||||
const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset)
|
|
||||||
? prevSettings.myset
|
|
||||||
: null;
|
|
||||||
const nextSettings = {
|
|
||||||
...prevSettings,
|
|
||||||
...input,
|
|
||||||
} as Record<string, unknown>;
|
|
||||||
if (typeof prevMyset === 'number') {
|
|
||||||
nextSettings.myset = Math.max(0, prevMyset - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.db.general.update({
|
|
||||||
where: { id: general.id },
|
|
||||||
data: {
|
|
||||||
meta: {
|
|
||||||
...metaRecord,
|
|
||||||
userSettings: nextSettings,
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
|
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
|
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
|
||||||
import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic';
|
import {
|
||||||
|
ItemLoader,
|
||||||
|
isItemKey,
|
||||||
|
loadWarTraitModules,
|
||||||
|
WarTraitLoader,
|
||||||
|
WAR_TRAIT_KEYS,
|
||||||
|
isWarTraitKey,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
appendInheritanceLog,
|
appendInheritanceLog,
|
||||||
@@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [
|
|||||||
'warAvoidRatio',
|
'warAvoidRatio',
|
||||||
'warCriticalRatio',
|
'warCriticalRatio',
|
||||||
'warMagicTrialProb',
|
'warMagicTrialProb',
|
||||||
'success',
|
'domesticSuccessProb',
|
||||||
'fail',
|
'domesticFailProb',
|
||||||
'warAvoidRatioOppose',
|
'warAvoidRatioOppose',
|
||||||
'warCriticalRatioOppose',
|
'warCriticalRatioOppose',
|
||||||
'warMagicTrialProbOppose',
|
'warMagicTrialProbOppose',
|
||||||
@@ -34,8 +41,8 @@ const BUFF_LABELS: Record<InheritBuffType, string> = {
|
|||||||
warAvoidRatio: '회피 확률 증가',
|
warAvoidRatio: '회피 확률 증가',
|
||||||
warCriticalRatio: '필살 확률 증가',
|
warCriticalRatio: '필살 확률 증가',
|
||||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||||
success: '내정 성공률 증가',
|
domesticSuccessProb: '내정 성공률 증가',
|
||||||
fail: '내정 실패율 감소',
|
domesticFailProb: '내정 실패율 감소',
|
||||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||||
@@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
|||||||
|
|
||||||
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
||||||
|
|
||||||
|
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||||
|
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||||
|
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
|
||||||
|
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
|
||||||
|
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
|
||||||
|
for (const entries of Object.values(asRecord(configuredItems))) {
|
||||||
|
for (const [key, amount] of Object.entries(asRecord(entries))) {
|
||||||
|
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
|
||||||
|
enabledKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = new ItemLoader();
|
||||||
|
const items = await Promise.all(
|
||||||
|
[...new Set(enabledKeys)].map(async (key) => {
|
||||||
|
const item = await loader.load(key);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name: item.name,
|
||||||
|
rawName: item.rawName,
|
||||||
|
info: item.info ?? '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return items.sort((left, right) => left.name.localeCompare(right.name, 'ko'));
|
||||||
|
};
|
||||||
|
|
||||||
const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => {
|
const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => {
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
if (!worldState || typeof worldState !== 'object') {
|
if (!worldState || typeof worldState !== 'object') {
|
||||||
@@ -199,6 +237,9 @@ export const inheritRouter = router({
|
|||||||
special2Code: true,
|
special2Code: true,
|
||||||
meta: true,
|
meta: true,
|
||||||
turnTime: true,
|
turnTime: true,
|
||||||
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +260,7 @@ export const inheritRouter = router({
|
|||||||
const inheritConst = resolveInheritConstants(worldState);
|
const inheritConst = resolveInheritConstants(worldState);
|
||||||
const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
||||||
const buffLevels = BUFF_KEYS.reduce<Record<string, number>>((acc, key) => {
|
const buffLevels = BUFF_KEYS.reduce<Record<string, number>>((acc, key) => {
|
||||||
acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0)));
|
acc[key] = readBuffLevel(buffState, key);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
@@ -240,11 +281,14 @@ export const inheritRouter = router({
|
|||||||
info: trait.info ?? '',
|
info: trait.info ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const others = await ctx.db.general.findMany({
|
const [others, availableUnique] = await Promise.all([
|
||||||
where: { id: { not: general.id }, userId: { not: null } },
|
ctx.db.general.findMany({
|
||||||
select: { id: true, name: true },
|
where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } },
|
||||||
orderBy: { id: 'asc' },
|
select: { id: true, name: true },
|
||||||
});
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
loadAvailableUniqueItems(worldState),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
@@ -260,10 +304,16 @@ export const inheritRouter = router({
|
|||||||
resetTurnTime: resetTurnLevel,
|
resetTurnTime: resetTurnLevel,
|
||||||
},
|
},
|
||||||
availableSpecialWar: warSpecials,
|
availableSpecialWar: warSpecials,
|
||||||
|
availableUnique,
|
||||||
availableTargetGenerals: others,
|
availableTargetGenerals: others,
|
||||||
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
|
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
|
||||||
isUnited,
|
isUnited,
|
||||||
currentSpecialWar: general.special2Code ?? 'None',
|
currentSpecialWar: general.special2Code ?? 'None',
|
||||||
|
currentStat: {
|
||||||
|
leadership: general.leadership,
|
||||||
|
strength: general.strength,
|
||||||
|
intel: general.intel,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getLogs: authedProcedure
|
getLogs: authedProcedure
|
||||||
@@ -285,7 +335,7 @@ export const inheritRouter = router({
|
|||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
take: 30,
|
take: 30,
|
||||||
select: { id: true, year: true, month: true, text: true },
|
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
||||||
});
|
});
|
||||||
return logs;
|
return logs;
|
||||||
}),
|
}),
|
||||||
@@ -318,7 +368,7 @@ export const inheritRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
||||||
const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0)));
|
const prevLevel = readBuffLevel(buff, input.type);
|
||||||
if (input.level === prevLevel) {
|
if (input.level === prevLevel) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
|
||||||
}
|
}
|
||||||
@@ -417,7 +467,12 @@ export const inheritRouter = router({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint);
|
await setInheritancePoint(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
'previous',
|
||||||
|
currentPoint - inheritConst.inheritSpecificSpecialPoint
|
||||||
|
);
|
||||||
await appendInheritanceLog(
|
await appendInheritanceLog(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
userId,
|
userId,
|
||||||
@@ -460,7 +515,8 @@ export const inheritRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = asRecord(general.meta);
|
const meta = asRecord(general.meta);
|
||||||
const prevList = parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
const prevList =
|
||||||
|
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
||||||
prevList.push(general.special2Code);
|
prevList.push(general.special2Code);
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
await patchGeneral(ctx, general.id, {
|
||||||
@@ -473,7 +529,13 @@ export const inheritRouter = router({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
||||||
await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`);
|
await appendInheritanceLog(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
worldState.currentYear,
|
||||||
|
worldState.currentMonth,
|
||||||
|
`${cost} 포인트로 전투 특기 초기화`
|
||||||
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
|
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
|
||||||
@@ -624,9 +686,7 @@ export const inheritRouter = router({
|
|||||||
const finalBonus =
|
const finalBonus =
|
||||||
bonusSum === 0
|
bonusSum === 0
|
||||||
? buildRandomBonus(
|
? buildRandomBonus(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
|
||||||
`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`
|
|
||||||
),
|
|
||||||
[input.leadership, input.strength, input.intel]
|
[input.leadership, input.strength, input.intel]
|
||||||
)
|
)
|
||||||
: (bonus as [number, number, number]);
|
: (bonus as [number, number, number]);
|
||||||
@@ -674,9 +734,7 @@ export const inheritRouter = router({
|
|||||||
if (seasonValue !== null) {
|
if (seasonValue !== null) {
|
||||||
const userState = await readUserStateMeta(ctx.db, userId);
|
const userState = await readUserStateMeta(ctx.db, userId);
|
||||||
const resetSeasons = readResetSeasons(userState);
|
const resetSeasons = readResetSeasons(userState);
|
||||||
const nextSeasons = resetSeasons.includes(seasonValue)
|
const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
|
||||||
? resetSeasons
|
|
||||||
: [...resetSeasons, seasonValue];
|
|
||||||
await writeUserStateMeta(ctx.db, userId, {
|
await writeUserStateMeta(ctx.db, userId, {
|
||||||
...userState,
|
...userState,
|
||||||
last_stat_reset: nextSeasons,
|
last_stat_reset: nextSeasons,
|
||||||
@@ -709,7 +767,10 @@ export const inheritRouter = router({
|
|||||||
}
|
}
|
||||||
const meta = asRecord(general.meta);
|
const meta = asRecord(general.meta);
|
||||||
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
|
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' });
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
await patchGeneral(ctx, general.id, {
|
||||||
@@ -803,7 +864,9 @@ export const inheritRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId;
|
const rawOwnerName = asRecord(target.meta).ownerName;
|
||||||
|
const ownerName =
|
||||||
|
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
|
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
|
||||||
await appendInheritanceLog(
|
await appendInheritanceLog(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +28,75 @@ import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js
|
|||||||
|
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
|
|
||||||
|
const redactDiplomacyMessages = (messages: MessageView[], permission: number): MessageView[] => {
|
||||||
|
if (permission >= 3) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (!message.dest || message.dest.nationId === 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
text: '(외교 메시지입니다)',
|
||||||
|
option: {
|
||||||
|
...(message.option ?? {}),
|
||||||
|
invalid: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFutureDate = (value: string | undefined, now = Date.now()): boolean => {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => {
|
||||||
|
if (
|
||||||
|
isFutureDate(sanctions.mutedUntil) ||
|
||||||
|
isFutureDate(sanctions.suspendedUntil) ||
|
||||||
|
isFutureDate(sanctions.bannedUntil)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const profileName of profileNames) {
|
||||||
|
const restriction = sanctions.serverRestrictions?.[profileName];
|
||||||
|
if (!restriction) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (restriction.until && !isFutureDate(restriction.until)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (restriction.blockedFeatures?.includes('messages')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => {
|
||||||
|
const value = asRecord(penalty)[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPenalty = (penalty: unknown, key: string): boolean => {
|
||||||
|
const value = asRecord(penalty)[key];
|
||||||
|
return value === true || value === 1 || value === '1';
|
||||||
|
};
|
||||||
|
|
||||||
export const messagesRouter = router({
|
export const messagesRouter = router({
|
||||||
getRecent: authedProcedure
|
getRecent: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -85,11 +156,12 @@ export const messagesRouter = router({
|
|||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||||
private: privateMessages,
|
private: privateMessages,
|
||||||
public: publicMessages,
|
public: publicMessages,
|
||||||
national: nationalMessages,
|
national: nationalMessages,
|
||||||
diplomacy: diplomacyMessages,
|
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
|
||||||
};
|
};
|
||||||
|
|
||||||
let nextSequence = sequence;
|
let nextSequence = sequence;
|
||||||
@@ -128,10 +200,8 @@ export const messagesRouter = router({
|
|||||||
sequence: nextSequence,
|
sequence: nextSequence,
|
||||||
nationId: nationId,
|
nationId: nationId,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
canRespondDiplomacy:
|
permission,
|
||||||
general.officerLevel > 4 &&
|
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||||
nation !== null &&
|
|
||||||
resolveNationPermission(general, nation.meta, false) >= 4,
|
|
||||||
latestRead: {
|
latestRead: {
|
||||||
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||||
private: readState?.latestPrivateMessage ?? 0,
|
private: readState?.latestPrivateMessage ?? 0,
|
||||||
@@ -178,6 +248,7 @@ export const messagesRouter = router({
|
|||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
nation: nationList.map((nation) => ({
|
nation: nationList.map((nation) => ({
|
||||||
|
nationId: nation.id,
|
||||||
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
|
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
|
||||||
name: nation.name,
|
name: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
@@ -234,14 +305,24 @@ export const messagesRouter = router({
|
|||||||
if (message.payload.src.generalId !== general.id) {
|
if (message.payload.src.generalId !== general.id) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
|
||||||
}
|
}
|
||||||
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) {
|
if (message.msgType === 'diplomacy' && message.payload.option?.action) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '시스템 외교 메시지는 삭제할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (message.payload.option?.deletable === false) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
||||||
}
|
}
|
||||||
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
|
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||||
}
|
}
|
||||||
const receiverMessageId = message.payload.option?.receiverMessageID;
|
const receiverMessageId = message.payload.option?.receiverMessageID;
|
||||||
const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])];
|
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||||
|
const ids = [
|
||||||
|
message.id,
|
||||||
|
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||||
|
];
|
||||||
await invalidateMessages(ctx.db, ids);
|
await invalidateMessages(ctx.db, ids);
|
||||||
return { ok: true, deletedIds: ids };
|
return { ok: true, deletedIds: ids };
|
||||||
}),
|
}),
|
||||||
@@ -291,6 +372,14 @@ export const messagesRouter = router({
|
|||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
|
||||||
const nationId = general.nationId;
|
const nationId = general.nationId;
|
||||||
|
const nation =
|
||||||
|
nationId > 0
|
||||||
|
? await ctx.db.nation.findUnique({
|
||||||
|
where: { id: nationId },
|
||||||
|
select: { meta: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||||
const mailboxes = {
|
const mailboxes = {
|
||||||
private: general.id,
|
private: general.id,
|
||||||
public: MESSAGE_MAILBOX_PUBLIC,
|
public: MESSAGE_MAILBOX_PUBLIC,
|
||||||
@@ -312,7 +401,8 @@ export const messagesRouter = router({
|
|||||||
toSeq: input.to,
|
toSeq: input.to,
|
||||||
limit: 15,
|
limit: 15,
|
||||||
});
|
});
|
||||||
messageBuckets[input.type] = messages;
|
messageBuckets[input.type] =
|
||||||
|
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: true,
|
result: true,
|
||||||
@@ -320,6 +410,7 @@ export const messagesRouter = router({
|
|||||||
sequence: 0,
|
sequence: 0,
|
||||||
nationId,
|
nationId,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
|
permission,
|
||||||
...messageBuckets,
|
...messageBuckets,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -333,6 +424,12 @@ export const messagesRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '메시지 전송이 제한된 계정입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -340,28 +437,93 @@ export const messagesRouter = router({
|
|||||||
|
|
||||||
let msgType: MessageType;
|
let msgType: MessageType;
|
||||||
let dest = src;
|
let dest = src;
|
||||||
|
let receiverMailbox = input.mailbox;
|
||||||
|
|
||||||
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||||
msgType = 'public';
|
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
|
||||||
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
|
||||||
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
|
||||||
if (destNationId <= 0) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'FORBIDDEN',
|
||||||
message: 'Invalid nation mailbox.',
|
message: '공개 메세지를 보낼 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
msgType = 'public';
|
||||||
|
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
||||||
|
const sourceNation =
|
||||||
|
general.nationId > 0
|
||||||
|
? await ctx.db.nation.findUnique({
|
||||||
|
where: { id: general.nationId },
|
||||||
|
select: { meta: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const permission =
|
||||||
|
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
|
||||||
|
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||||
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
|
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
|
||||||
|
if (destNationId > 0) {
|
||||||
|
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
|
||||||
|
if (!destNation) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: '존재하지 않는 국가입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
|
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
|
||||||
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
||||||
|
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
|
||||||
} else if (input.mailbox > 0) {
|
} else if (input.mailbox > 0) {
|
||||||
|
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '개인 메세지를 보낼 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const intervalSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
|
||||||
|
);
|
||||||
|
if (intervalSeconds > 0) {
|
||||||
|
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
|
||||||
|
const acquired = await ctx.redis.set(rateLimitKey, '1', {
|
||||||
|
NX: true,
|
||||||
|
PX: intervalSeconds * 1000,
|
||||||
|
});
|
||||||
|
if (acquired === null) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const destGeneral = await ctx.db.general.findUnique({
|
const destGeneral = await ctx.db.general.findUnique({
|
||||||
where: { id: input.mailbox },
|
where: { id: input.mailbox },
|
||||||
});
|
});
|
||||||
if (!destGeneral) {
|
if (!destGeneral) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'Destination general not found.',
|
message: '존재하지 않는 유저입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [sourceNation, destNation] = await Promise.all([
|
||||||
|
general.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
|
||||||
|
: null,
|
||||||
|
destGeneral.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
const sourcePermission =
|
||||||
|
sourceNation && general.nationId > 0
|
||||||
|
? resolveNationPermission(general, sourceNation.meta, false)
|
||||||
|
: -1;
|
||||||
|
const destPermission =
|
||||||
|
destNation && destGeneral.nationId > 0
|
||||||
|
? resolveNationPermission(destGeneral, destNation.meta, false)
|
||||||
|
: -1;
|
||||||
|
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
|
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
|
||||||
@@ -394,7 +556,7 @@ export const messagesRouter = router({
|
|||||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||||
type: 'messageCreated',
|
type: 'messageCreated',
|
||||||
at: now.toISOString(),
|
at: now.toISOString(),
|
||||||
mailbox: input.mailbox,
|
mailbox: receiverMailbox,
|
||||||
msgType,
|
msgType,
|
||||||
messageId: result.receiverId,
|
messageId: result.receiverId,
|
||||||
senderId: general.id,
|
senderId: general.id,
|
||||||
|
|||||||
@@ -2,7 +2,21 @@ import { TRPCError } from '@trpc/server';
|
|||||||
|
|
||||||
import { authedProcedure } from '../../../trpc.js';
|
import { authedProcedure } from '../../../trpc.js';
|
||||||
import { getMyGeneral } from '../../shared/general.js';
|
import { getMyGeneral } from '../../shared/general.js';
|
||||||
import { assertNationAccess, loadTraitNames, mapGeneralList, resolveChiefStatMin } from '../shared.js';
|
import {
|
||||||
|
assertNationAccess,
|
||||||
|
loadTraitNames,
|
||||||
|
mapGeneralList,
|
||||||
|
resolveChiefStatMin,
|
||||||
|
resolveNationPermission,
|
||||||
|
} from '../shared.js';
|
||||||
|
|
||||||
|
const experienceLevel = (experience: number): number =>
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||||
|
);
|
||||||
|
const dedicationLevel = (dedication: number): number =>
|
||||||
|
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10)));
|
||||||
|
|
||||||
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
|
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
@@ -62,7 +76,38 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
|
|||||||
const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name]));
|
const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name]));
|
||||||
const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
|
const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
|
||||||
const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap);
|
const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap);
|
||||||
|
const accessRows = generalRows.length
|
||||||
|
? await ctx.db.generalAccessLog.findMany({
|
||||||
|
where: { generalId: { in: generalRows.map((entry) => entry.id) } },
|
||||||
|
select: { generalId: true, refreshScoreTotal: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal]));
|
||||||
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
|
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
|
||||||
|
const permission = resolveNationPermission(general, nation.meta, true);
|
||||||
|
const visibleList = list.map((entry) => {
|
||||||
|
const { permission: _targetPermission, ...safeEntry } = entry;
|
||||||
|
if (permission >= 1) {
|
||||||
|
return {
|
||||||
|
...safeEntry,
|
||||||
|
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
|
||||||
|
experienceLevel: experienceLevel(entry.experience),
|
||||||
|
dedicationLevel: dedicationLevel(entry.dedication),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry;
|
||||||
|
return {
|
||||||
|
...visible,
|
||||||
|
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
|
||||||
|
officerLevel: entry.officerLevel >= 5 ? entry.officerLevel : Math.min(1, entry.officerLevel),
|
||||||
|
cityName: null,
|
||||||
|
troopName: null,
|
||||||
|
officerCity: 0,
|
||||||
|
officerCityName: null,
|
||||||
|
experienceLevel: experienceLevel(entry.experience),
|
||||||
|
dedicationLevel: dedicationLevel(entry.dedication),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nation: {
|
nation: {
|
||||||
@@ -79,6 +124,7 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
|
|||||||
capitalCityId: nation.capitalCityId ?? 0,
|
capitalCityId: nation.capitalCityId ?? 0,
|
||||||
},
|
},
|
||||||
chiefStatMin: resolveChiefStatMin(worldState),
|
chiefStatMin: resolveChiefStatMin(worldState),
|
||||||
generals: list,
|
viewer: { generalId: general.id, permission },
|
||||||
|
generals: visibleList,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import { authedProcedure } from '../../../trpc.js';
|
||||||
|
import { getMyGeneral } from '../../shared/general.js';
|
||||||
|
import { assertNationAccess, resolveNationPermission } from '../shared.js';
|
||||||
|
|
||||||
|
const readNumber = (record: Record<string, unknown>, keys: string[], fallback = 0): number => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
const woundedStat = (value: number, injury: number): number =>
|
||||||
|
injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value;
|
||||||
|
const experienceLevel = (experience: number): number =>
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||||
|
);
|
||||||
|
const leadershipBonus = (officerLevel: number, nationLevel: number): number =>
|
||||||
|
officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0;
|
||||||
|
const defenceTrainText = (value: number): string =>
|
||||||
|
value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△';
|
||||||
|
|
||||||
|
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
assertNationAccess(me);
|
||||||
|
const nation = await ctx.db.nation.findUnique({
|
||||||
|
where: { id: me.nationId },
|
||||||
|
select: { id: true, name: true, color: true, level: true, meta: true },
|
||||||
|
});
|
||||||
|
if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||||
|
const permission = resolveNationPermission(me, nation.meta, true);
|
||||||
|
if (permission < 1) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cities, troops, generalRows] = await Promise.all([
|
||||||
|
ctx.db.city.findMany({ select: { id: true, name: true } }),
|
||||||
|
ctx.db.troop.findMany({
|
||||||
|
where: { nationId: me.nationId },
|
||||||
|
select: { troopLeaderId: true, name: true },
|
||||||
|
}),
|
||||||
|
ctx.db.general.findMany({
|
||||||
|
where: { nationId: me.nationId },
|
||||||
|
orderBy: [{ turnTime: 'asc' }, { id: 'asc' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const generalIds = generalRows.map((general) => general.id);
|
||||||
|
const turns = generalIds.length
|
||||||
|
? await ctx.db.generalTurn.findMany({
|
||||||
|
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
|
||||||
|
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||||
|
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||||
|
const troopNames = new Map(troops.map((troop) => [troop.troopLeaderId, troop.name]));
|
||||||
|
const turnMap = new Map<number, string[]>();
|
||||||
|
for (const turn of turns) {
|
||||||
|
const list = turnMap.get(turn.generalId) ?? [];
|
||||||
|
list[turn.turnIdx] = turn.actionCode;
|
||||||
|
turnMap.set(turn.generalId, list);
|
||||||
|
}
|
||||||
|
const generals = generalRows.map((general) => {
|
||||||
|
const meta = asRecord(general.meta);
|
||||||
|
const defenceTrain = readNumber(meta, ['defenceTrain', 'defence_train'], 80);
|
||||||
|
return {
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
npcState: general.npcState,
|
||||||
|
injury: general.injury,
|
||||||
|
stats: {
|
||||||
|
leadership: woundedStat(general.leadership, general.injury),
|
||||||
|
strength: woundedStat(general.strength, general.injury),
|
||||||
|
intelligence: woundedStat(general.intel, general.injury),
|
||||||
|
},
|
||||||
|
leadershipBonus: leadershipBonus(general.officerLevel, nation.level),
|
||||||
|
experienceLevel: experienceLevel(general.experience),
|
||||||
|
troopId: general.troopId,
|
||||||
|
troopName: troopNames.get(general.troopId) ?? null,
|
||||||
|
gold: general.gold,
|
||||||
|
rice: general.rice,
|
||||||
|
cityId: general.cityId,
|
||||||
|
cityName: cityNames.get(general.cityId) ?? null,
|
||||||
|
defenceTrain,
|
||||||
|
defenceTrainText: defenceTrainText(defenceTrain),
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
crew: general.crew,
|
||||||
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
|
killTurn: readNumber(meta, ['killturn', 'killTurn']),
|
||||||
|
turnTime: general.turnTime.toISOString(),
|
||||||
|
reservedCommands: general.npcState < 2 ? (turnMap.get(general.id) ?? []) : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const counted = generals.filter((general) => general.npcState !== 5);
|
||||||
|
const summary = counted.reduce(
|
||||||
|
(result, general) => {
|
||||||
|
result.gold += general.gold;
|
||||||
|
result.rice += general.rice;
|
||||||
|
result.crew += general.crew;
|
||||||
|
if (general.crew > 0) {
|
||||||
|
for (const threshold of [90, 80, 60] as const) {
|
||||||
|
if (general.train >= threshold && general.atmos >= threshold) {
|
||||||
|
result.readiness[threshold].crew += general.crew;
|
||||||
|
result.readiness[threshold].generals += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
crew: 0,
|
||||||
|
readiness: {
|
||||||
|
90: { crew: 0, generals: 0 },
|
||||||
|
80: { crew: 0, generals: 0 },
|
||||||
|
60: { crew: 0, generals: 0 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level },
|
||||||
|
viewer: { generalId: me.id, permission },
|
||||||
|
summary: {
|
||||||
|
...summary,
|
||||||
|
generalCount: counted.length,
|
||||||
|
averageGold: counted.length ? summary.gold / counted.length : 0,
|
||||||
|
averageRice: counted.length ? summary.rice / counted.length : 0,
|
||||||
|
},
|
||||||
|
generals,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { getBattleCenter } from './endpoints/getBattleCenter.js';
|
|||||||
import { getChiefCenter } from './endpoints/getChiefCenter.js';
|
import { getChiefCenter } from './endpoints/getChiefCenter.js';
|
||||||
import { getCityOverview } from './endpoints/getCityOverview.js';
|
import { getCityOverview } from './endpoints/getCityOverview.js';
|
||||||
import { getGeneralList } from './endpoints/getGeneralList.js';
|
import { getGeneralList } from './endpoints/getGeneralList.js';
|
||||||
|
import { getSecretGeneralList } from './endpoints/getSecretGeneralList.js';
|
||||||
import { getGeneralLog } from './endpoints/getGeneralLog.js';
|
import { getGeneralLog } from './endpoints/getGeneralLog.js';
|
||||||
import { getNationInfo } from './endpoints/getNationInfo.js';
|
import { getNationInfo } from './endpoints/getNationInfo.js';
|
||||||
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
|
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
|
||||||
@@ -21,6 +22,7 @@ import { setSecretLimit } from './endpoints/setSecretLimit.js';
|
|||||||
export const nationRouter = router({
|
export const nationRouter = router({
|
||||||
getNationInfo,
|
getNationInfo,
|
||||||
getGeneralList,
|
getGeneralList,
|
||||||
|
getSecretGeneralList,
|
||||||
getCityOverview,
|
getCityOverview,
|
||||||
getPersonnelInfo,
|
getPersonnelInfo,
|
||||||
getStratFinan,
|
getStratFinan,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const;
|
|||||||
type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number];
|
type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number];
|
||||||
type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number];
|
type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number];
|
||||||
|
|
||||||
const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset');
|
|
||||||
|
|
||||||
const readNumber = (value: unknown, fallback = 0): number => {
|
const readNumber = (value: unknown, fallback = 0): number => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -353,8 +349,8 @@ const buildZeroPolicy = async (
|
|||||||
}
|
}
|
||||||
): Promise<NationPolicy> => {
|
): Promise<NationPolicy> => {
|
||||||
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
|
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
|
||||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT });
|
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
||||||
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId);
|
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
|
||||||
const techCost = getTechCost(nationTech);
|
const techCost = getTechCost(nationTech);
|
||||||
const next = clonePolicy(policy);
|
const next = clonePolicy(policy);
|
||||||
|
|
||||||
@@ -364,7 +360,7 @@ const buildZeroPolicy = async (
|
|||||||
|
|
||||||
if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) {
|
if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) {
|
||||||
const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0;
|
const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0;
|
||||||
const baseRice = statNpcMax;
|
const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0;
|
||||||
if (next.reqNPCWarGold === 0) {
|
if (next.reqNPCWarGold === 0) {
|
||||||
next.reqNPCWarGold = roundTo(baseGold * 4, -2);
|
next.reqNPCWarGold = roundTo(baseGold * 4, -2);
|
||||||
}
|
}
|
||||||
@@ -375,7 +371,7 @@ const buildZeroPolicy = async (
|
|||||||
|
|
||||||
if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) {
|
if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) {
|
||||||
const baseGold = crewType ? crewType.cost * techCost * statMax : 0;
|
const baseGold = crewType ? crewType.cost * techCost * statMax : 0;
|
||||||
const baseRice = statMax;
|
const baseRice = crewType ? crewType.rice * techCost * statMax : 0;
|
||||||
if (next.reqHumanWarUrgentGold === 0) {
|
if (next.reqHumanWarUrgentGold === 0) {
|
||||||
next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
|
next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
|
||||||
}
|
}
|
||||||
@@ -415,8 +411,6 @@ const resolveSetterInfo = (policy: Record<string, unknown>, kind: 'value' | 'pri
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority));
|
|
||||||
|
|
||||||
const validateGeneralPriority = (priority: string[]): string | null => {
|
const validateGeneralPriority = (priority: string[]): string | null => {
|
||||||
const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
|
const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
|
||||||
const mustHave = new Set(['출병', '일반내정']);
|
const mustHave = new Set(['출병', '일반내정']);
|
||||||
@@ -461,6 +455,7 @@ export const npcRouter = router({
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
level: true,
|
level: true,
|
||||||
|
tech: true,
|
||||||
meta: true,
|
meta: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -508,9 +503,9 @@ export const npcRouter = router({
|
|||||||
const stat = resolveScenarioStat(config);
|
const stat = resolveScenarioStat(config);
|
||||||
const env = resolveCommandEnv(config);
|
const env = resolveCommandEnv(config);
|
||||||
const unitSetName = resolveUnitSetName(config, 'che');
|
const unitSetName = resolveUnitSetName(config, 'che');
|
||||||
const nationTech = readNumber(asRecord(nationMeta).tech, 0);
|
const nationTech = readNumber(nation.tech, 0);
|
||||||
|
|
||||||
const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, {
|
const zeroPolicy = await buildZeroPolicy(DEFAULT_NATION_POLICY, {
|
||||||
statMax: stat.max,
|
statMax: stat.max,
|
||||||
statNpcMax: stat.npcMax,
|
statNpcMax: stat.npcMax,
|
||||||
nationTech,
|
nationTech,
|
||||||
@@ -542,277 +537,272 @@ export const npcRouter = router({
|
|||||||
permissionLevel,
|
permissionLevel,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
setNationPolicy: authedProcedure
|
setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
|
||||||
.input(z.record(z.string(), z.unknown()))
|
const general = await getMyGeneral(ctx);
|
||||||
.mutation(async ({ ctx, input }) => {
|
if (general.nationId <= 0) {
|
||||||
const general = await getMyGeneral(ctx);
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||||
if (general.nationId <= 0) {
|
}
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nation = await ctx.db.nation.findUnique({
|
const nation = await ctx.db.nation.findUnique({
|
||||||
where: { id: general.nationId },
|
where: { id: general.nationId },
|
||||||
select: { id: true, meta: true },
|
select: { id: true, meta: true },
|
||||||
});
|
});
|
||||||
if (!nation) {
|
if (!nation) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionLevel = resolveSecretPermission(
|
const permissionLevel = resolveSecretPermission(
|
||||||
{
|
{
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
officerLevel: general.officerLevel,
|
officerLevel: general.officerLevel,
|
||||||
meta: general.meta,
|
meta: general.meta,
|
||||||
penalty: general.penalty,
|
penalty: general.penalty,
|
||||||
},
|
},
|
||||||
nation.meta
|
nation.meta
|
||||||
);
|
);
|
||||||
if (permissionLevel < 3) {
|
if (permissionLevel < 3) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(input);
|
const keys = Object.keys(input);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const troopRows = await ctx.db.troop.findMany({
|
||||||
|
where: { nationId: general.nationId },
|
||||||
|
select: { troopLeaderId: true },
|
||||||
|
});
|
||||||
|
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
|
||||||
|
|
||||||
|
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
|
||||||
|
const citySet = new Set(cityRows.map((row) => row.id));
|
||||||
|
const assigned = new Set<number>();
|
||||||
|
|
||||||
|
const nationMeta = asRecord(nation.meta);
|
||||||
|
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||||
|
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
|
||||||
|
|
||||||
|
for (const key of INTEGER_POLICY_KEYS) {
|
||||||
|
if (!(key in input)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = input[key];
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||||
|
}
|
||||||
|
nextValues[key] = Math.max(0, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of FLOAT_POLICY_KEYS) {
|
||||||
|
if (!(key in input)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = input[key];
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||||
|
}
|
||||||
|
nextValues[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('CombatForce' in input) {
|
||||||
|
const rawCombat = input.CombatForce;
|
||||||
|
if (!isRecord(rawCombat)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
|
||||||
|
}
|
||||||
|
const combatForce: Record<number, [number, number]> = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
|
||||||
|
const leaderId = Number(rawKey);
|
||||||
|
if (!Number.isFinite(leaderId)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
|
||||||
|
}
|
||||||
|
if (!troopSet.has(leaderId)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
|
||||||
|
}
|
||||||
|
if (assigned.has(leaderId)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Array.isArray(rawValue) || rawValue.length < 2) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `${leaderId}의 입력양식이 올바르지 않습니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const fromCity = Number(rawValue[0]);
|
||||||
|
const toCity = Number(rawValue[1]);
|
||||||
|
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
combatForce[leaderId] = [fromCity, toCity];
|
||||||
|
assigned.add(leaderId);
|
||||||
|
}
|
||||||
|
nextValues.CombatForce = combatForce;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||||
|
if (!(key in input)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rawList = input[key];
|
||||||
|
if (!Array.isArray(rawList)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||||
|
}
|
||||||
|
const list: number[] = [];
|
||||||
|
for (const rawValue of rawList) {
|
||||||
|
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||||
}
|
}
|
||||||
}
|
if (!troopSet.has(rawValue)) {
|
||||||
|
throw new TRPCError({
|
||||||
const troopRows = await ctx.db.troop.findMany({
|
code: 'BAD_REQUEST',
|
||||||
where: { nationId: general.nationId },
|
message: `${rawValue}는 국가의 부대가 아닙니다.`,
|
||||||
select: { troopLeaderId: true },
|
});
|
||||||
});
|
|
||||||
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
|
|
||||||
|
|
||||||
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
|
|
||||||
const citySet = new Set(cityRows.map((row) => row.id));
|
|
||||||
const assigned = new Set<number>();
|
|
||||||
|
|
||||||
const nationMeta = asRecord(nation.meta);
|
|
||||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
|
||||||
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
|
|
||||||
|
|
||||||
for (const key of INTEGER_POLICY_KEYS) {
|
|
||||||
if (!(key in input)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const value = input[key];
|
if (assigned.has(rawValue)) {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
throw new TRPCError({
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
code: 'BAD_REQUEST',
|
||||||
|
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
nextValues[key] = Math.max(0, value);
|
assigned.add(rawValue);
|
||||||
|
list.push(rawValue);
|
||||||
}
|
}
|
||||||
|
if (key === 'SupportForce') {
|
||||||
for (const key of FLOAT_POLICY_KEYS) {
|
nextValues.SupportForce = list;
|
||||||
if (!(key in input)) {
|
} else {
|
||||||
continue;
|
nextValues.DevelopForce = list;
|
||||||
}
|
|
||||||
const value = input[key];
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
|
||||||
}
|
|
||||||
nextValues[key] = Math.max(0, value);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ('CombatForce' in input) {
|
const nextPolicyRoot = {
|
||||||
const rawCombat = input.CombatForce;
|
...policyRoot,
|
||||||
if (!isRecord(rawCombat)) {
|
values: nextValues,
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
|
valueSetter: general.name,
|
||||||
}
|
valueSetTime: new Date().toISOString(),
|
||||||
const combatForce: Record<number, [number, number]> = {};
|
};
|
||||||
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
|
|
||||||
const leaderId = Number(rawKey);
|
await updateNationMeta(
|
||||||
if (!Number.isFinite(leaderId)) {
|
ctx,
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
|
nation.id,
|
||||||
}
|
{
|
||||||
if (!troopSet.has(leaderId)) {
|
npc_nation_policy: nextPolicyRoot,
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
|
},
|
||||||
}
|
nationMeta
|
||||||
if (assigned.has(leaderId)) {
|
);
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
return { ok: true };
|
||||||
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
|
}),
|
||||||
});
|
setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||||
}
|
const general = await getMyGeneral(ctx);
|
||||||
if (!Array.isArray(rawValue) || rawValue.length < 2) {
|
if (general.nationId <= 0) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` });
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||||
}
|
}
|
||||||
const fromCity = Number(rawValue[0]);
|
|
||||||
const toCity = Number(rawValue[1]);
|
const nation = await ctx.db.nation.findUnique({
|
||||||
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
|
where: { id: general.nationId },
|
||||||
throw new TRPCError({
|
select: { id: true, meta: true },
|
||||||
code: 'BAD_REQUEST',
|
});
|
||||||
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
|
if (!nation) {
|
||||||
});
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||||
}
|
}
|
||||||
combatForce[leaderId] = [fromCity, toCity];
|
|
||||||
assigned.add(leaderId);
|
const permissionLevel = resolveSecretPermission(
|
||||||
}
|
{
|
||||||
nextValues.CombatForce = combatForce;
|
nationId: general.nationId,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
meta: general.meta,
|
||||||
|
penalty: general.penalty,
|
||||||
|
},
|
||||||
|
nation.meta
|
||||||
|
);
|
||||||
|
if (permissionLevel < 3) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of input) {
|
||||||
|
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
const nationMeta = asRecord(nation.meta);
|
||||||
if (!(key in input)) {
|
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||||
continue;
|
const nextPolicyRoot = {
|
||||||
}
|
...policyRoot,
|
||||||
const rawList = input[key];
|
priority: input,
|
||||||
if (!Array.isArray(rawList)) {
|
prioritySetter: general.name,
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
prioritySetTime: new Date().toISOString(),
|
||||||
}
|
};
|
||||||
const list: number[] = [];
|
|
||||||
for (const rawValue of rawList) {
|
|
||||||
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
|
||||||
}
|
|
||||||
if (!troopSet.has(rawValue)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: `${rawValue}는 국가의 부대가 아닙니다.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (assigned.has(rawValue)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
assigned.add(rawValue);
|
|
||||||
list.push(rawValue);
|
|
||||||
}
|
|
||||||
if (key === 'SupportForce') {
|
|
||||||
nextValues.SupportForce = list;
|
|
||||||
} else {
|
|
||||||
nextValues.DevelopForce = list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPolicyRoot = {
|
await updateNationMeta(
|
||||||
...policyRoot,
|
ctx,
|
||||||
values: nextValues,
|
nation.id,
|
||||||
valueSetter: general.name,
|
{
|
||||||
valueSetTime: new Date().toISOString(),
|
npc_nation_policy: nextPolicyRoot,
|
||||||
};
|
},
|
||||||
|
nationMeta
|
||||||
|
);
|
||||||
|
|
||||||
await updateNationMeta(
|
return { ok: true };
|
||||||
ctx,
|
}),
|
||||||
nation.id,
|
setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||||
{
|
const general = await getMyGeneral(ctx);
|
||||||
npc_nation_policy: nextPolicyRoot,
|
if (general.nationId <= 0) {
|
||||||
},
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||||
nationMeta
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return { ok: true };
|
const nation = await ctx.db.nation.findUnique({
|
||||||
}),
|
where: { id: general.nationId },
|
||||||
setNationPriority: authedProcedure
|
select: { id: true, meta: true },
|
||||||
.input(z.array(z.string()))
|
});
|
||||||
.mutation(async ({ ctx, input }) => {
|
if (!nation) {
|
||||||
const general = await getMyGeneral(ctx);
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||||
if (general.nationId <= 0) {
|
}
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nation = await ctx.db.nation.findUnique({
|
const permissionLevel = resolveSecretPermission(
|
||||||
where: { id: general.nationId },
|
{
|
||||||
select: { id: true, meta: true },
|
nationId: general.nationId,
|
||||||
});
|
officerLevel: general.officerLevel,
|
||||||
if (!nation) {
|
meta: general.meta,
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
penalty: general.penalty,
|
||||||
}
|
},
|
||||||
|
nation.meta
|
||||||
|
);
|
||||||
|
if (permissionLevel < 3) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
const permissionLevel = resolveSecretPermission(
|
const validationError = validateGeneralPriority(input);
|
||||||
{
|
if (validationError) {
|
||||||
nationId: general.nationId,
|
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
|
||||||
officerLevel: general.officerLevel,
|
}
|
||||||
meta: general.meta,
|
|
||||||
penalty: general.penalty,
|
|
||||||
},
|
|
||||||
nation.meta
|
|
||||||
);
|
|
||||||
if (permissionLevel < 3) {
|
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const unique = ensureUniquePriority(input);
|
const nationMeta = asRecord(nation.meta);
|
||||||
for (const item of unique) {
|
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
const nextPolicyRoot = {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
|
...policyRoot,
|
||||||
}
|
priority: input,
|
||||||
}
|
prioritySetter: general.name,
|
||||||
|
prioritySetTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
const nationMeta = asRecord(nation.meta);
|
await updateNationMeta(
|
||||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
ctx,
|
||||||
const nextPolicyRoot = {
|
nation.id,
|
||||||
...policyRoot,
|
{
|
||||||
priority: unique,
|
npc_general_policy: nextPolicyRoot,
|
||||||
prioritySetter: general.name,
|
},
|
||||||
prioritySetTime: new Date().toISOString(),
|
nationMeta
|
||||||
};
|
);
|
||||||
|
|
||||||
await updateNationMeta(
|
return { ok: true };
|
||||||
ctx,
|
}),
|
||||||
nation.id,
|
|
||||||
{
|
|
||||||
npc_nation_policy: nextPolicyRoot,
|
|
||||||
},
|
|
||||||
nationMeta
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}),
|
|
||||||
setGeneralPriority: authedProcedure
|
|
||||||
.input(z.array(z.string()))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const general = await getMyGeneral(ctx);
|
|
||||||
if (general.nationId <= 0) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nation = await ctx.db.nation.findUnique({
|
|
||||||
where: { id: general.nationId },
|
|
||||||
select: { id: true, meta: true },
|
|
||||||
});
|
|
||||||
if (!nation) {
|
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionLevel = resolveSecretPermission(
|
|
||||||
{
|
|
||||||
nationId: general.nationId,
|
|
||||||
officerLevel: general.officerLevel,
|
|
||||||
meta: general.meta,
|
|
||||||
penalty: general.penalty,
|
|
||||||
},
|
|
||||||
nation.meta
|
|
||||||
);
|
|
||||||
if (permissionLevel < 3) {
|
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const unique = ensureUniquePriority(input);
|
|
||||||
const validationError = validateGeneralPriority(unique);
|
|
||||||
if (validationError) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nationMeta = asRecord(nation.meta);
|
|
||||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
|
||||||
const nextPolicyRoot = {
|
|
||||||
...policyRoot,
|
|
||||||
priority: unique,
|
|
||||||
prioritySetter: general.name,
|
|
||||||
prioritySetTime: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateNationMeta(
|
|
||||||
ctx,
|
|
||||||
nation.id,
|
|
||||||
{
|
|
||||||
npc_general_policy: nextPolicyRoot,
|
|
||||||
},
|
|
||||||
nationMeta
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ type NationCountRow = {
|
|||||||
|
|
||||||
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||||
|
|
||||||
|
type TrafficHistoryItem = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
refresh: number;
|
||||||
|
online: number;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
|
||||||
const PUBLIC_CACHE_TTL_SECONDS = 600;
|
const PUBLIC_CACHE_TTL_SECONDS = 600;
|
||||||
|
|
||||||
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
|
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
|
||||||
@@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: TrafficHistoryItem[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
const row = asRecord(item);
|
||||||
|
const year = readFiniteMetaNumber(row, 'year');
|
||||||
|
const month = readFiniteMetaNumber(row, 'month');
|
||||||
|
const refresh = readFiniteMetaNumber(row, 'refresh');
|
||||||
|
const online = readFiniteMetaNumber(row, 'online');
|
||||||
|
const date = typeof row.date === 'string' ? row.date : '';
|
||||||
|
if (year > 0 && month > 0 && date) {
|
||||||
|
result.push({ year, month, refresh, online, date });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const compareString = (left: string, right: string): number => {
|
const compareString = (left: string, right: string): number => {
|
||||||
if (left === right) {
|
if (left === right) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -222,6 +250,95 @@ export const publicRouter = router({
|
|||||||
getNationList: procedure.query(async ({ ctx }) => {
|
getNationList: procedure.query(async ({ ctx }) => {
|
||||||
return loadCachedNationList(ctx);
|
return loadCachedNationList(ctx);
|
||||||
}),
|
}),
|
||||||
|
getTraffic: procedure.query(async ({ ctx }) => {
|
||||||
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
|
if (!worldState) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'World state is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = asRecord(worldState.meta);
|
||||||
|
const rawOnlineSince = meta.lastTurnTime ?? meta.turntime;
|
||||||
|
const parsedOnlineSince =
|
||||||
|
typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date
|
||||||
|
? new Date(rawOnlineSince)
|
||||||
|
: null;
|
||||||
|
const onlineSince =
|
||||||
|
parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime())
|
||||||
|
? parsedOnlineSince
|
||||||
|
: new Date(Date.now() - worldState.tickSeconds * 1_000);
|
||||||
|
const [accessTotal, currentOnline, topAccess] = await Promise.all([
|
||||||
|
ctx.db.generalAccessLog.aggregate({
|
||||||
|
_sum: {
|
||||||
|
refresh: true,
|
||||||
|
refreshScoreTotal: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.generalAccessLog.count({
|
||||||
|
where: {
|
||||||
|
lastRefresh: {
|
||||||
|
gte: onlineSince,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.generalAccessLog.findMany({
|
||||||
|
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }],
|
||||||
|
take: 5,
|
||||||
|
select: {
|
||||||
|
generalId: true,
|
||||||
|
refresh: true,
|
||||||
|
refreshScoreTotal: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const generalIds = topAccess.map((entry) => entry.generalId);
|
||||||
|
const generalRows =
|
||||||
|
generalIds.length > 0
|
||||||
|
? await ctx.db.general.findMany({
|
||||||
|
where: { id: { in: generalIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const generalName = new Map(generalRows.map((general) => [general.id, general.name]));
|
||||||
|
const totalRefresh = accessTotal._sum.refresh ?? 0;
|
||||||
|
const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
|
||||||
|
const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh);
|
||||||
|
const history = parseTrafficHistory(meta.recentTraffic);
|
||||||
|
history.push({
|
||||||
|
year: worldState.currentYear,
|
||||||
|
month: worldState.currentMonth,
|
||||||
|
refresh: currentRefresh,
|
||||||
|
online: currentOnline,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
maxRefresh: Math.max(
|
||||||
|
1,
|
||||||
|
readFiniteMetaNumber(meta, 'maxrefresh'),
|
||||||
|
...history.map((entry) => entry.refresh)
|
||||||
|
),
|
||||||
|
maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)),
|
||||||
|
suspects: [
|
||||||
|
{
|
||||||
|
generalId: null,
|
||||||
|
name: '접속자 총합',
|
||||||
|
refresh: totalRefresh,
|
||||||
|
refreshScoreTotal: totalRefreshScore,
|
||||||
|
},
|
||||||
|
...topAccess.map((entry) => ({
|
||||||
|
generalId: entry.generalId,
|
||||||
|
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
|
||||||
|
refresh: entry.refresh,
|
||||||
|
refreshScoreTotal: entry.refreshScoreTotal,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
getGeneralList: procedure.query(async ({ ctx }) => {
|
getGeneralList: procedure.query(async ({ ctx }) => {
|
||||||
const [generals, nations] = await Promise.all([
|
const [generals, nations] = await Promise.all([
|
||||||
ctx.db.general.findMany({
|
ctx.db.general.findMany({
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
||||||
import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js';
|
import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js';
|
||||||
|
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||||
|
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
|
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
|
|
||||||
import { procedure, router } from '../../trpc.js';
|
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
const DEFAULT_BG_COLOR = '#2b2b2b';
|
const DEFAULT_BG_COLOR = '#2b2b2b';
|
||||||
const DEFAULT_FG_COLOR = '#ffffff';
|
const DEFAULT_FG_COLOR = '#ffffff';
|
||||||
@@ -23,31 +26,31 @@ const readMetaNumber = (value: unknown): number => {
|
|||||||
|
|
||||||
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
|
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
|
||||||
|
|
||||||
|
const readOwnerDisplayName = (value: unknown): string | null => {
|
||||||
|
const meta = asRecord(value);
|
||||||
|
if (typeof meta.ownerName === 'string' && meta.ownerName.length > 0) {
|
||||||
|
return meta.ownerName;
|
||||||
|
}
|
||||||
|
if (typeof meta.owner_name === 'string' && meta.owner_name.length > 0) {
|
||||||
|
return meta.owner_name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const itemLoader = new ItemLoader();
|
const itemLoader = new ItemLoader();
|
||||||
let cachedUniqueItems: Promise<
|
let cachedUniqueItems: Promise<ItemModule[]> | null = null;
|
||||||
Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }>
|
|
||||||
> | null = null;
|
|
||||||
|
|
||||||
const loadUniqueItems = () => {
|
const loadUniqueItems = () => {
|
||||||
if (!cachedUniqueItems) {
|
if (!cachedUniqueItems) {
|
||||||
cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) =>
|
cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) =>
|
||||||
modules
|
modules.filter((module) => module.unique && !module.buyable)
|
||||||
.filter((module) => module.unique && !module.buyable)
|
|
||||||
.map((module) => ({
|
|
||||||
key: module.key,
|
|
||||||
name: module.name,
|
|
||||||
slot: module.slot,
|
|
||||||
unique: module.unique,
|
|
||||||
buyable: module.buyable,
|
|
||||||
info: module.info,
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return cachedUniqueItems;
|
return cachedUniqueItems;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rankingRouter = router({
|
export const rankingRouter = router({
|
||||||
getBestGeneral: procedure
|
getBestGeneral: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -57,7 +60,7 @@ export const rankingRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const worldState = await ctx.db.worldState.findFirst({
|
const worldState = await ctx.db.worldState.findFirst({
|
||||||
select: { meta: true },
|
select: { meta: true, config: true },
|
||||||
});
|
});
|
||||||
const meta = asRecord(worldState?.meta);
|
const meta = asRecord(worldState?.meta);
|
||||||
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
|
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
|
||||||
@@ -76,6 +79,7 @@ export const rankingRouter = router({
|
|||||||
userId: true,
|
userId: true,
|
||||||
picture: true,
|
picture: true,
|
||||||
imageServer: true,
|
imageServer: true,
|
||||||
|
meta: true,
|
||||||
experience: true,
|
experience: true,
|
||||||
dedication: true,
|
dedication: true,
|
||||||
horseCode: true,
|
horseCode: true,
|
||||||
@@ -185,7 +189,7 @@ export const rankingRouter = router({
|
|||||||
let display = {
|
let display = {
|
||||||
id: general.id,
|
id: general.id,
|
||||||
name: general.name,
|
name: general.name,
|
||||||
ownerName: general.userId ?? null,
|
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
|
||||||
nationName: nation?.name ?? '재야',
|
nationName: nation?.name ?? '재야',
|
||||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
||||||
fgColor: DEFAULT_FG_COLOR,
|
fgColor: DEFAULT_FG_COLOR,
|
||||||
@@ -217,46 +221,91 @@ export const rankingRouter = router({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const uniqueItems = await loadUniqueItems();
|
const uniqueItems = await loadUniqueItems();
|
||||||
const itemEntries = uniqueItems.map((item) => {
|
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
|
||||||
const owners = generals.filter((general) => {
|
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
|
||||||
if (item.slot === 'horse') {
|
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
||||||
return general.horseCode === item.key;
|
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
||||||
|
}
|
||||||
|
const activeAuctions = await ctx.db.auction.findMany({
|
||||||
|
where: {
|
||||||
|
type: 'UNIQUE_ITEM',
|
||||||
|
status: { in: ['OPEN', 'FINALIZING'] },
|
||||||
|
targetCode: { not: null },
|
||||||
|
},
|
||||||
|
select: { targetCode: true },
|
||||||
|
});
|
||||||
|
const auctionCounts = new Map<string, number>();
|
||||||
|
for (const auction of activeAuctions) {
|
||||||
|
if (auction.targetCode) {
|
||||||
|
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const slotTitles = {
|
||||||
|
horse: '명 마',
|
||||||
|
weapon: '명 검',
|
||||||
|
book: '명 서',
|
||||||
|
item: '도 구',
|
||||||
|
} as const;
|
||||||
|
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
|
||||||
|
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
|
||||||
|
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
|
||||||
|
const item = itemRegistry.get(itemKey);
|
||||||
|
if (!item || item.buyable) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
if (item.slot === 'weapon') {
|
const owners = generals
|
||||||
return general.weaponCode === item.key;
|
.filter((general) => {
|
||||||
|
if (slot === 'horse') {
|
||||||
|
return general.horseCode === itemKey;
|
||||||
|
}
|
||||||
|
if (slot === 'weapon') {
|
||||||
|
return general.weaponCode === itemKey;
|
||||||
|
}
|
||||||
|
if (slot === 'book') {
|
||||||
|
return general.bookCode === itemKey;
|
||||||
|
}
|
||||||
|
return general.itemCode === itemKey;
|
||||||
|
})
|
||||||
|
.map((general) => {
|
||||||
|
const nation = nationMap.get(general.nationId) ?? null;
|
||||||
|
return {
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
nationName: nation?.name ?? '재야',
|
||||||
|
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
||||||
|
fgColor: DEFAULT_FG_COLOR,
|
||||||
|
picture: general.picture ?? null,
|
||||||
|
imageServer: general.imageServer ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
|
||||||
|
owners.push({
|
||||||
|
id: 0,
|
||||||
|
name: '경매중',
|
||||||
|
nationName: '-',
|
||||||
|
bgColor: '#00582c',
|
||||||
|
fgColor: '#ffffff',
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (item.slot === 'book') {
|
const count = Math.max(0, Math.floor(rawCount));
|
||||||
return general.bookCode === item.key;
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
}
|
itemKey,
|
||||||
return general.itemCode === item.key;
|
itemName: item.name,
|
||||||
|
itemInfo: item.info,
|
||||||
|
owner: owners[index] ?? {
|
||||||
|
id: 0,
|
||||||
|
name: '미발견',
|
||||||
|
nationName: '-',
|
||||||
|
bgColor: DEFAULT_BG_COLOR,
|
||||||
|
fgColor: DEFAULT_FG_COLOR,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
return { title: slotTitles[slot], slot, entries };
|
||||||
const displayOwners = owners.length
|
|
||||||
? owners.map((general) => {
|
|
||||||
const nation = nationMap.get(general.nationId) ?? null;
|
|
||||||
return {
|
|
||||||
id: general.id,
|
|
||||||
name: general.name,
|
|
||||||
nationName: nation?.name ?? '재야',
|
|
||||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
|
||||||
fgColor: DEFAULT_FG_COLOR,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
name: '미발견',
|
|
||||||
nationName: '-',
|
|
||||||
bgColor: DEFAULT_BG_COLOR,
|
|
||||||
fgColor: DEFAULT_FG_COLOR,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: item.name,
|
|
||||||
slot: item.slot,
|
|
||||||
owners: displayOwners,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -339,6 +388,10 @@ export const rankingRouter = router({
|
|||||||
return {
|
return {
|
||||||
generalId: row.generalNo,
|
generalId: row.generalNo,
|
||||||
name: String(aux.name ?? ''),
|
name: String(aux.name ?? ''),
|
||||||
|
ownerName:
|
||||||
|
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
|
||||||
|
? aux.ownerDisplayName
|
||||||
|
: null,
|
||||||
nationName: String(aux.nationName ?? ''),
|
nationName: String(aux.nationName ?? ''),
|
||||||
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
|
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
|
||||||
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
|
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js';
|
|||||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||||
import { loadWorldMap } from '../../maps/worldMap.js';
|
import { loadWorldMap } from '../../maps/worldMap.js';
|
||||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||||
|
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
|
||||||
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
|
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
|
||||||
|
|
||||||
const isWorldAdmin = (roles: readonly string[]): boolean =>
|
const isWorldAdmin = (roles: readonly string[]): boolean =>
|
||||||
@@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => {
|
|||||||
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
|
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unitSetName = (world: WorldStateRow | null, fallback: string): string => {
|
||||||
|
const config = asRecord(world?.config);
|
||||||
|
const environment = asRecord(config.environment ?? config.map);
|
||||||
|
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
|
||||||
|
const loadCrewTypeNames = (name: string): Promise<Map<number, string>> => {
|
||||||
|
const cached = crewTypeNameCache.get(name);
|
||||||
|
if (cached) return cached;
|
||||||
|
const pending = loadUnitSetDefinitionByName(name)
|
||||||
|
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
|
||||||
|
.catch(() => new Map<number, string>());
|
||||||
|
crewTypeNameCache.set(name, pending);
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
|
||||||
|
if (officerLevel === 12) return nationLevel * 2;
|
||||||
|
if (officerLevel >= 5) return nationLevel;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||||
scenarioCode: row.scenarioCode,
|
scenarioCode: row.scenarioCode,
|
||||||
currentYear: row.currentYear,
|
currentYear: row.currentYear,
|
||||||
@@ -122,6 +146,8 @@ export const worldRouter = router({
|
|||||||
if (me.officerLevel > 0 && me.nationId > 0) {
|
if (me.officerLevel > 0 && me.nationId > 0) {
|
||||||
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
|
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
|
||||||
nationGenerals.forEach((general) => selectable.add(general.cityId));
|
nationGenerals.forEach((general) => selectable.add(general.cityId));
|
||||||
|
}
|
||||||
|
if ((nation?.level ?? 0) > 0) {
|
||||||
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
|
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
|
||||||
}
|
}
|
||||||
if (admin) cities.forEach((city) => selectable.add(city.id));
|
if (admin) cities.forEach((city) => selectable.add(city.id));
|
||||||
@@ -150,6 +176,8 @@ export const worldRouter = router({
|
|||||||
turnMap.set(turn.generalId, list);
|
turnMap.set(turn.generalId, list);
|
||||||
}
|
}
|
||||||
const nationMap = new Map(nations.map((item) => [item.id, item]));
|
const nationMap = new Map(nations.map((item) => [item.id, item]));
|
||||||
|
const selectedNation = nationMap.get(selected.nationId);
|
||||||
|
const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id));
|
||||||
const officers = await ctx.db.general.findMany({
|
const officers = await ctx.db.general.findMany({
|
||||||
where: { officerLevel: { in: [2, 3, 4] } },
|
where: { officerLevel: { in: [2, 3, 4] } },
|
||||||
select: { name: true, officerLevel: true, meta: true },
|
select: { name: true, officerLevel: true, meta: true },
|
||||||
@@ -175,14 +203,59 @@ export const worldRouter = router({
|
|||||||
intelligence: general.intel,
|
intelligence: general.intel,
|
||||||
injury: general.injury,
|
injury: general.injury,
|
||||||
officerLevel: general.officerLevel,
|
officerLevel: general.officerLevel,
|
||||||
|
leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0),
|
||||||
defenceTrain: ours ? defenceTrain(general.meta) : null,
|
defenceTrain: ours ? defenceTrain(general.meta) : null,
|
||||||
crewTypeId: ours ? general.crewTypeId : null,
|
crewTypeId: ours ? general.crewTypeId : null,
|
||||||
|
crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null,
|
||||||
crew: ours || full ? general.crew : null,
|
crew: ours || full ? general.crew : null,
|
||||||
train: ours ? general.train : null,
|
train: ours ? general.train : null,
|
||||||
atmos: ours ? general.atmos : null,
|
atmos: ours ? general.atmos : null,
|
||||||
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
|
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const forceSummary = mappedGenerals.reduce(
|
||||||
|
(summary, general) => {
|
||||||
|
if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) {
|
||||||
|
summary.enemyGenerals += 1;
|
||||||
|
if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew;
|
||||||
|
if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
if (me.nationId <= 0 || general.nationId !== me.nationId) return summary;
|
||||||
|
summary.ownGenerals += 1;
|
||||||
|
summary.ownCrew += general.crew ?? 0;
|
||||||
|
if ((general.crew ?? 0) <= 0) return summary;
|
||||||
|
summary.ownArmedGenerals += 1;
|
||||||
|
const readiness = Math.min(general.train ?? -1, general.atmos ?? -1);
|
||||||
|
if (readiness >= 90) {
|
||||||
|
summary.ready90Crew += general.crew ?? 0;
|
||||||
|
summary.ready90Generals += 1;
|
||||||
|
}
|
||||||
|
if (readiness >= 60) {
|
||||||
|
summary.ready60Crew += general.crew ?? 0;
|
||||||
|
summary.ready60Generals += 1;
|
||||||
|
}
|
||||||
|
if (general.defenceTrain !== null && readiness >= general.defenceTrain) {
|
||||||
|
summary.defenceReadyCrew += general.crew ?? 0;
|
||||||
|
summary.defenceReadyGenerals += 1;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enemyCrew: 0,
|
||||||
|
enemyArmedGenerals: 0,
|
||||||
|
enemyGenerals: 0,
|
||||||
|
ownCrew: 0,
|
||||||
|
ownArmedGenerals: 0,
|
||||||
|
ownGenerals: 0,
|
||||||
|
ready90Crew: 0,
|
||||||
|
ready90Generals: 0,
|
||||||
|
ready60Crew: 0,
|
||||||
|
ready60Generals: 0,
|
||||||
|
defenceReadyCrew: 0,
|
||||||
|
defenceReadyGenerals: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
|
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
|
||||||
options: [...selectable]
|
options: [...selectable]
|
||||||
@@ -194,6 +267,7 @@ export const worldRouter = router({
|
|||||||
id: selected.id,
|
id: selected.id,
|
||||||
name: selected.name,
|
name: selected.name,
|
||||||
nationId: selected.nationId,
|
nationId: selected.nationId,
|
||||||
|
nationColor: selectedNation?.color ?? '#000000',
|
||||||
level: selected.level,
|
level: selected.level,
|
||||||
region: selected.region,
|
region: selected.region,
|
||||||
population: redact(selected.population),
|
population: redact(selected.population),
|
||||||
@@ -217,8 +291,11 @@ export const worldRouter = router({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
generals: mappedGenerals,
|
generals: mappedGenerals,
|
||||||
|
forceSummary,
|
||||||
lastExecute:
|
lastExecute:
|
||||||
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '',
|
typeof asRecord(world?.meta).turntime === 'string'
|
||||||
|
? String(asRecord(world?.meta).turntime).slice(5, 19)
|
||||||
|
: '',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getState: procedure.query(async ({ ctx }) => {
|
getState: procedure.query(async ({ ctx }) => {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { createTournamentRng } from '@sammo-ts/common';
|
import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
import { resolveTournamentBattle } from '@sammo-ts/logic';
|
import { resolveTournamentBattle } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
resolvePostgresConfigFromEnv,
|
resolvePostgresConfigFromEnv,
|
||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
|
type GamePrismaClient,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { resolveGameApiConfigFromEnv } from '../config.js';
|
import { resolveGameApiConfigFromEnv } from '../config.js';
|
||||||
import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js';
|
import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
|
||||||
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
|
|
||||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||||
import { buildTournamentKeys } from './keys.js';
|
import { buildTournamentKeys } from './keys.js';
|
||||||
import { TournamentStore } from './store.js';
|
import { TournamentStore } from './store.js';
|
||||||
@@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let settledState: TournamentState | null = null;
|
let settledState = state;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
if (!state.rewardSettled) {
|
const requireSuccessfulResult = (
|
||||||
|
result: TurnDaemonCommandResult | null,
|
||||||
|
expectedType: TurnDaemonCommandResult['type']
|
||||||
|
): void => {
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`);
|
||||||
|
}
|
||||||
|
if (result.type !== expectedType) {
|
||||||
|
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settledState.rewardSettled) {
|
||||||
const matches = await store.getMatches();
|
const matches = await store.getMatches();
|
||||||
const rewardPayload = buildTournamentRewardPayload(matches);
|
const rewardPayload = buildTournamentRewardPayload(matches);
|
||||||
await daemonTransport.sendCommand({
|
const result = await daemonTransport.requestCommand({
|
||||||
type: 'tournamentReward',
|
type: 'tournamentReward',
|
||||||
|
requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`,
|
||||||
tournamentType: state.type,
|
tournamentType: state.type,
|
||||||
winnerId: rewardPayload.winnerId,
|
winnerId: rewardPayload.winnerId,
|
||||||
runnerUpId: rewardPayload.runnerUpId,
|
runnerUpId: rewardPayload.runnerUpId,
|
||||||
@@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: {
|
|||||||
top8: rewardPayload.top8,
|
top8: rewardPayload.top8,
|
||||||
top4: rewardPayload.top4,
|
top4: rewardPayload.top4,
|
||||||
});
|
});
|
||||||
|
requireSuccessfulResult(result, 'tournamentReward');
|
||||||
settledState = {
|
settledState = {
|
||||||
...(settledState ?? state),
|
...settledState,
|
||||||
rewardSettled: true,
|
rewardSettled: true,
|
||||||
};
|
};
|
||||||
|
changed = true;
|
||||||
|
await store.setState(settledState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.bettingId && !state.bettingSettled) {
|
if (settledState.bettingId && !settledState.bettingSettled) {
|
||||||
const bettingEntries = await store.getBettingEntries();
|
const bettingEntries = await store.getBettingEntries();
|
||||||
if (bettingEntries.length > 0) {
|
if (bettingEntries.length > 0) {
|
||||||
const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries);
|
const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries);
|
||||||
if (payoutInfo.payouts.length > 0) {
|
if (payoutInfo.payouts.length > 0) {
|
||||||
if (payoutInfo.refundAll) {
|
if (payoutInfo.refundAll) {
|
||||||
await daemonTransport.sendCommand({
|
const result = await daemonTransport.requestCommand({
|
||||||
type: 'tournamentRefund',
|
type: 'tournamentRefund',
|
||||||
bettingId: state.bettingId,
|
requestId: `tournament:${settledState.bettingId}:betting-refund`,
|
||||||
|
bettingId: settledState.bettingId,
|
||||||
refunds: payoutInfo.payouts,
|
refunds: payoutInfo.payouts,
|
||||||
reason: 'no_winner',
|
reason: 'no_winner',
|
||||||
});
|
});
|
||||||
|
requireSuccessfulResult(result, 'tournamentRefund');
|
||||||
} else {
|
} else {
|
||||||
await daemonTransport.sendCommand({
|
const result = await daemonTransport.requestCommand({
|
||||||
type: 'tournamentBettingPayout',
|
type: 'tournamentBettingPayout',
|
||||||
bettingId: state.bettingId,
|
requestId: `tournament:${settledState.bettingId}:betting-payout`,
|
||||||
|
bettingId: settledState.bettingId,
|
||||||
payouts: payoutInfo.payouts,
|
payouts: payoutInfo.payouts,
|
||||||
reason: 'winner_payout',
|
reason: 'winner_payout',
|
||||||
});
|
});
|
||||||
|
requireSuccessfulResult(result, 'tournamentBettingPayout');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settledState = {
|
settledState = {
|
||||||
...(settledState ?? state),
|
...settledState,
|
||||||
bettingSettled: true,
|
bettingSettled: true,
|
||||||
};
|
};
|
||||||
}
|
changed = true;
|
||||||
|
|
||||||
if (settledState) {
|
|
||||||
await store.setState(settledState);
|
await store.setState(settledState);
|
||||||
}
|
}
|
||||||
|
|
||||||
return settledState;
|
return changed ? settledState : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsSettlement = (state: TournamentState): boolean =>
|
||||||
|
state.stage === 0 &&
|
||||||
|
Boolean(state.winnerId) &&
|
||||||
|
(!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled));
|
||||||
|
|
||||||
|
export const processTournamentTick = async (options: {
|
||||||
|
store: TournamentStore;
|
||||||
|
prisma: GamePrismaClient;
|
||||||
|
daemonTransport: TurnDaemonTransport;
|
||||||
|
now?: () => number;
|
||||||
|
}): Promise<TournamentState | null> => {
|
||||||
|
const { store, prisma, daemonTransport } = options;
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
let processedState: TournamentState | null = null;
|
||||||
|
|
||||||
|
await store.withMutationLock(async () => {
|
||||||
|
const state = await store.getState();
|
||||||
|
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextAt = new Date(state.nextAt).getTime();
|
||||||
|
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSettlement(state)) {
|
||||||
|
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worldState = await prisma.worldState.findFirst();
|
||||||
|
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||||
|
let nextState = state;
|
||||||
|
if (isBattleStage(state.stage)) {
|
||||||
|
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||||
|
} else if (isPreBattleStage(state.stage)) {
|
||||||
|
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport);
|
||||||
|
}
|
||||||
|
processedState =
|
||||||
|
(await settleTournamentOutcome({
|
||||||
|
store,
|
||||||
|
daemonTransport,
|
||||||
|
state: nextState,
|
||||||
|
})) ?? nextState;
|
||||||
|
});
|
||||||
|
|
||||||
|
return processedState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runTournamentWorker = async (): Promise<void> => {
|
export const runTournamentWorker = async (): Promise<void> => {
|
||||||
@@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise<void> => {
|
|||||||
await redis.connect();
|
await redis.connect();
|
||||||
|
|
||||||
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
|
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
|
||||||
const daemonTransport = new RedisTurnDaemonTransport(redis.client, {
|
const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
|
||||||
keys: buildTurnDaemonStreamKeys(config.profileName),
|
|
||||||
requestTimeoutMs: config.daemonRequestTimeoutMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleExit = async () => {
|
const handleExit = async () => {
|
||||||
await redis.disconnect();
|
await redis.disconnect();
|
||||||
@@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise<void> => {
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const state = await store.getState();
|
const state = await store.getState();
|
||||||
if (!state || !state.auto) {
|
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||||
await sleepMs(config.tournamentPollMs);
|
await sleepMs(config.tournamentPollMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAt = new Date(state.nextAt).getTime();
|
const nextAt = new Date(state.nextAt).getTime();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (Number.isFinite(nextAt) && nextAt > now) {
|
if (state.auto && Number.isFinite(nextAt) && nextAt > now) {
|
||||||
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
|
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.withMutationLock(async () => {
|
await processTournamentTick({
|
||||||
const lockedState = await store.getState();
|
store,
|
||||||
if (!lockedState || !lockedState.auto) {
|
prisma: postgres.prisma,
|
||||||
return;
|
daemonTransport,
|
||||||
}
|
|
||||||
const lockedNextAt = new Date(lockedState.nextAt).getTime();
|
|
||||||
if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const worldState = await postgres.prisma.worldState.findFirst();
|
|
||||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
|
||||||
let nextState = lockedState;
|
|
||||||
if (isBattleStage(lockedState.stage)) {
|
|
||||||
nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport);
|
|
||||||
} else if (isPreBattleStage(lockedState.stage)) {
|
|
||||||
nextState = await applyPreBattleStage(
|
|
||||||
store,
|
|
||||||
postgres.prisma,
|
|
||||||
lockedState,
|
|
||||||
String(baseSeed),
|
|
||||||
daemonTransport
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await settleTournamentOutcome({
|
|
||||||
store,
|
|
||||||
daemonTransport,
|
|
||||||
state: nextState,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise<void> => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const currentState = (await store.getState()) ?? state;
|
||||||
const nextState: TournamentState = {
|
const nextState: TournamentState = {
|
||||||
...state,
|
...currentState,
|
||||||
auto: false,
|
|
||||||
lastError: message,
|
lastError: message,
|
||||||
lastErrorAt: now,
|
lastErrorAt: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -520,8 +520,9 @@ export const buildBettingPayouts = (
|
|||||||
const winners = entries.filter((entry) => entry.targetId === winnerId);
|
const winners = entries.filter((entry) => entry.targetId === winnerId);
|
||||||
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
|
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
|
||||||
if (winnersTotal <= 0) {
|
if (winnersTotal <= 0) {
|
||||||
const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount }));
|
// Legacy Betting::_calcRewardExclusive() builds a refund candidate list
|
||||||
return { payouts: refunds, total, refundAll: true };
|
// but returns no rewards when nobody selected the winner.
|
||||||
|
return { payouts: [], total, refundAll: false };
|
||||||
}
|
}
|
||||||
const ratio = total / winnersTotal;
|
const ratio = total / winnersTotal;
|
||||||
const payouts = winners.map((entry) => ({
|
const payouts = winners.map((entry) => ({
|
||||||
|
|||||||
+20
-14
@@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar
|
|||||||
|
|
||||||
const t = initTRPC.context<GameApiContext>().create();
|
const t = initTRPC.context<GameApiContext>().create();
|
||||||
|
|
||||||
|
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||||
|
if (!ctx.auth) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
auth: ctx.auth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
||||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||||
return next();
|
return next();
|
||||||
@@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
|||||||
|
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||||
export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
|
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||||
if (!ctx.auth) {
|
|
||||||
throw new TRPCError({
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||||
code: 'UNAUTHORIZED',
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||||
message: 'Unauthorized',
|
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||||
});
|
|
||||||
}
|
|
||||||
return next({
|
|
||||||
ctx: {
|
|
||||||
...ctx,
|
|
||||||
auth: ctx.auth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
|||||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||||
|
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
|
||||||
|
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
|
||||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,19 +19,30 @@ const profile: GameProfile = {
|
|||||||
class QueuedBattleSimTransport implements BattleSimTransport {
|
class QueuedBattleSimTransport implements BattleSimTransport {
|
||||||
public simulateCalls = 0;
|
public simulateCalls = 0;
|
||||||
public lastPayload: BattleSimJobPayload | null = null;
|
public lastPayload: BattleSimJobPayload | null = null;
|
||||||
|
public lastRequesterUserId: string | null = null;
|
||||||
|
private readonly owners = new Map<string, string>();
|
||||||
private readonly results = new Map<string, BattleSimResultPayload>();
|
private readonly results = new Map<string, BattleSimResultPayload>();
|
||||||
|
|
||||||
async simulate(payload: BattleSimJobPayload) {
|
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
|
||||||
this.simulateCalls += 1;
|
this.simulateCalls += 1;
|
||||||
this.lastPayload = payload;
|
this.lastPayload = payload;
|
||||||
return { status: 'queued', jobId: 'job-1' } as const;
|
this.lastRequesterUserId = requesterUserId;
|
||||||
|
const jobId = `job-${this.simulateCalls}`;
|
||||||
|
this.owners.set(jobId, requesterUserId);
|
||||||
|
return { status: 'queued', jobId } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSimulationResult(jobId: string) {
|
async getSimulationResult(jobId: string, requesterUserId: string) {
|
||||||
|
if (this.owners.get(jobId) !== requesterUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return this.results.get(jobId) ?? null;
|
return this.results.get(jobId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
pushResult(jobId: string, payload: BattleSimResultPayload) {
|
pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) {
|
||||||
|
if (this.owners.get(jobId) !== requesterUserId) {
|
||||||
|
throw new Error('requester mismatch');
|
||||||
|
}
|
||||||
this.results.set(jobId, payload);
|
this.results.set(jobId, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,8 +205,13 @@ const buildBattleRequest = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
|
const buildContext = (options: {
|
||||||
const db = {
|
state: WorldStateRow;
|
||||||
|
battleSim: BattleSimTransport;
|
||||||
|
userId?: string | null;
|
||||||
|
db?: Partial<DatabaseClient>;
|
||||||
|
}): GameApiContext => {
|
||||||
|
const db = options.db ?? {
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => options.state,
|
findFirst: async () => options.state,
|
||||||
},
|
},
|
||||||
@@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans
|
|||||||
},
|
},
|
||||||
profile.name
|
profile.name
|
||||||
);
|
);
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload | null =
|
||||||
version: 1,
|
options.userId === null
|
||||||
profile: profile.name,
|
? null
|
||||||
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
: {
|
||||||
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
version: 1,
|
||||||
sessionId: 'session-1',
|
profile: profile.name,
|
||||||
user: {
|
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
||||||
id: 'user-1',
|
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
||||||
username: 'tester',
|
sessionId: 'session-1',
|
||||||
displayName: 'Tester',
|
user: {
|
||||||
roles: [],
|
id: options.userId ?? 'user-1',
|
||||||
},
|
username: 'tester',
|
||||||
sanctions: {},
|
displayName: 'Tester',
|
||||||
};
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
db: db as unknown as DatabaseClient,
|
db: db as unknown as DatabaseClient,
|
||||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
@@ -255,14 +274,204 @@ describe('battle router orchestration', () => {
|
|||||||
const response = await caller.battle.simulate(buildBattleRequest());
|
const response = await caller.battle.simulate(buildBattleRequest());
|
||||||
expect(response.status).toBe('queued');
|
expect(response.status).toBe('queued');
|
||||||
expect(battleSim.simulateCalls).toBe(1);
|
expect(battleSim.simulateCalls).toBe(1);
|
||||||
|
expect(battleSim.lastRequesterUserId).toBe('user-1');
|
||||||
|
|
||||||
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
expect(queued.status).toBe('queued');
|
expect(queued.status).toBe('queued');
|
||||||
|
|
||||||
battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 });
|
battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 });
|
||||||
|
|
||||||
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
|
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
expect(completed.status).toBe('completed');
|
expect(completed.status).toBe('completed');
|
||||||
expect(completed.payload?.result).toBe(true);
|
expect(completed.payload?.result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
|
||||||
|
const battleSim = new QueuedBattleSimTransport();
|
||||||
|
const state: WorldStateRow = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
let transactionCalls = 0;
|
||||||
|
const db = {
|
||||||
|
worldState: { findFirst: async () => state },
|
||||||
|
$transaction: async () => {
|
||||||
|
transactionCalls += 1;
|
||||||
|
throw new Error('simulation must not create an input event transaction');
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseClient;
|
||||||
|
|
||||||
|
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
|
||||||
|
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const noGeneralUser = appRouter.createCaller(
|
||||||
|
buildContext({ state, battleSim, userId: 'user-without-general', db })
|
||||||
|
);
|
||||||
|
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
|
||||||
|
status: 'queued',
|
||||||
|
});
|
||||||
|
expect(transactionCalls).toBe(0);
|
||||||
|
expect(battleSim.lastRequesterUserId).toBe('user-without-general');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose queued results across authenticated users', async () => {
|
||||||
|
const battleSim = new QueuedBattleSimTransport();
|
||||||
|
const state: WorldStateRow = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' }));
|
||||||
|
const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' }));
|
||||||
|
const response = await owner.battle.simulate(buildBattleRequest());
|
||||||
|
battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 });
|
||||||
|
|
||||||
|
await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
payload: { avgWar: 7 },
|
||||||
|
});
|
||||||
|
await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({
|
||||||
|
status: 'queued',
|
||||||
|
jobId: response.jobId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('battle simulator general import permissions', () => {
|
||||||
|
const state: WorldStateRow = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGeneral = (overrides: Record<string, unknown>) => ({
|
||||||
|
id: 1,
|
||||||
|
userId: 'same-nation-user',
|
||||||
|
name: '관전자',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 1,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 71,
|
||||||
|
intel: 72,
|
||||||
|
officerLevel: 1,
|
||||||
|
injury: 0,
|
||||||
|
rice: 9000,
|
||||||
|
crew: 5000,
|
||||||
|
crewTypeId: 100,
|
||||||
|
atmos: 100,
|
||||||
|
train: 100,
|
||||||
|
experience: 400,
|
||||||
|
horseCode: null,
|
||||||
|
weaponCode: null,
|
||||||
|
bookCode: null,
|
||||||
|
itemCode: null,
|
||||||
|
personalCode: null,
|
||||||
|
special2Code: null,
|
||||||
|
meta: {},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 });
|
||||||
|
const ally = buildGeneral({
|
||||||
|
id: 2,
|
||||||
|
userId: 'ally-user',
|
||||||
|
name: '아군 장수',
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 4,
|
||||||
|
rice: 4321,
|
||||||
|
crew: 3210,
|
||||||
|
train: 97,
|
||||||
|
atmos: 96,
|
||||||
|
horseCode: 'che_적토마',
|
||||||
|
weaponCode: 'che_의천검',
|
||||||
|
bookCode: 'che_손자병법',
|
||||||
|
itemCode: 'che_옥새',
|
||||||
|
meta: {
|
||||||
|
dex1: 10000,
|
||||||
|
rank_warnum: 33,
|
||||||
|
rank_killnum: 22,
|
||||||
|
rank_killcrew: 1111,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 });
|
||||||
|
const generals = [actor, ally, foreignActor];
|
||||||
|
const db = {
|
||||||
|
worldState: { findFirst: async () => state },
|
||||||
|
general: {
|
||||||
|
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||||
|
generals.find((general) => general.userId === where.userId) ?? null,
|
||||||
|
findUnique: async ({ where }: { where: { id: number } }) =>
|
||||||
|
generals.find((general) => general.id === where.id) ?? null,
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseClient;
|
||||||
|
|
||||||
|
it('returns full ally details to the same nation but redacts them for another nation', async () => {
|
||||||
|
const battleSim = new QueuedBattleSimTransport();
|
||||||
|
const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db }));
|
||||||
|
const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db }));
|
||||||
|
|
||||||
|
const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id });
|
||||||
|
expect(visible.general).toMatchObject({
|
||||||
|
name: '아군 장수',
|
||||||
|
officer_level: 4,
|
||||||
|
horse: 'che_적토마',
|
||||||
|
crew: 3210,
|
||||||
|
rice: 4321,
|
||||||
|
train: 97,
|
||||||
|
atmos: 96,
|
||||||
|
warnum: 33,
|
||||||
|
killnum: 22,
|
||||||
|
killcrew: 1111,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id });
|
||||||
|
expect(redacted.general).toMatchObject({
|
||||||
|
name: '아군 장수',
|
||||||
|
officer_level: 1,
|
||||||
|
horse: null,
|
||||||
|
weapon: null,
|
||||||
|
book: null,
|
||||||
|
item: null,
|
||||||
|
crew: 0,
|
||||||
|
rice: 10000,
|
||||||
|
dex1: 0,
|
||||||
|
warnum: 0,
|
||||||
|
killnum: 0,
|
||||||
|
killcrew: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a game general only for server-side general import', async () => {
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
state,
|
||||||
|
battleSim: new QueuedBattleSimTransport(),
|
||||||
|
userId: 'user-without-general',
|
||||||
|
db,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'General not found',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
|
||||||
|
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
|
||||||
|
import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js';
|
||||||
|
|
||||||
|
class FakeRedisClient {
|
||||||
|
readonly values = new Map<string, string>();
|
||||||
|
readonly lists = new Map<string, string[]>();
|
||||||
|
|
||||||
|
async rPush(key: string, value: string): Promise<number> {
|
||||||
|
const list = this.lists.get(key) ?? [];
|
||||||
|
list.push(value);
|
||||||
|
this.lists.set(key, list);
|
||||||
|
return list.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async blPop(): Promise<null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string): Promise<'OK'> {
|
||||||
|
this.values.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
return this.values.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expire(): Promise<number> {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RedisBattleSimTransport requester isolation', () => {
|
||||||
|
it('records the requester on queued jobs and scopes completed results to that user', async () => {
|
||||||
|
const client = new FakeRedisClient();
|
||||||
|
const keys = buildBattleSimQueueKeys('che:test');
|
||||||
|
const transport = new RedisBattleSimTransport(client, {
|
||||||
|
keys,
|
||||||
|
requestTimeoutMs: 1,
|
||||||
|
resultTtlSeconds: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await transport.simulate({} as BattleSimJobPayload, 'user/one');
|
||||||
|
expect(response.status).toBe('queued');
|
||||||
|
|
||||||
|
const queuedRaw = client.lists.get(keys.queueKey)?.[0];
|
||||||
|
expect(queuedRaw).toBeTruthy();
|
||||||
|
expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({
|
||||||
|
jobId: response.jobId,
|
||||||
|
requesterUserId: 'user/one',
|
||||||
|
});
|
||||||
|
|
||||||
|
await transport.pushResult(response.jobId, 'user/one', {
|
||||||
|
result: true,
|
||||||
|
reason: 'success',
|
||||||
|
avgWar: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({
|
||||||
|
result: true,
|
||||||
|
avgWar: 3,
|
||||||
|
});
|
||||||
|
await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull();
|
||||||
|
expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildBattleSimEnvironment } from '../src/battleSim/environment.js';
|
||||||
|
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
|
||||||
|
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
|
||||||
|
import type { BattleSimRequestPayload } from '../src/battleSim/types.js';
|
||||||
|
import { runBattleSimWorker } from '../src/battleSim/worker.js';
|
||||||
|
import type { WorldStateRow } from '../src/context.js';
|
||||||
|
|
||||||
|
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
liveDescribe('battle simulator worker with live Redis', () => {
|
||||||
|
it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => {
|
||||||
|
const scenario = `battle-sim-e2e-${randomUUID()}`;
|
||||||
|
const profileName = `che:${scenario}`;
|
||||||
|
const requesterUserId = 'worker-e2e-user';
|
||||||
|
vi.stubEnv('PROFILE', 'che');
|
||||||
|
vi.stubEnv('SCENARIO', scenario);
|
||||||
|
vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only');
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'../../tools/integration-tests/fixtures/battle/basic-infantry.json'
|
||||||
|
);
|
||||||
|
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & {
|
||||||
|
startYear: number;
|
||||||
|
};
|
||||||
|
const { startYear, ...request } = fixture;
|
||||||
|
const worldState: WorldStateRow = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: request.year,
|
||||||
|
currentMonth: request.month,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: { scenarioMeta: { startYear } },
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const environment = await buildBattleSimEnvironment(worldState, 'che');
|
||||||
|
const payload = {
|
||||||
|
...request,
|
||||||
|
unitSet: environment.unitSet,
|
||||||
|
config: environment.config,
|
||||||
|
time: { year: request.year, month: request.month, startYear },
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
await clientConnector.connect();
|
||||||
|
const keys = buildBattleSimQueueKeys(profileName);
|
||||||
|
const transport = new RedisBattleSimTransport(clientConnector.client, {
|
||||||
|
keys,
|
||||||
|
requestTimeoutMs: 15_000,
|
||||||
|
resultTtlSeconds: 60,
|
||||||
|
});
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const worker = runBattleSimWorker({ signal: abortController.signal });
|
||||||
|
let jobId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await transport.simulate(payload, requesterUserId);
|
||||||
|
jobId = result.jobId;
|
||||||
|
expect(result.status).toBe('completed');
|
||||||
|
if (result.status === 'completed') {
|
||||||
|
expect(result.payload).toMatchObject({
|
||||||
|
result: true,
|
||||||
|
reason: 'success',
|
||||||
|
avgWar: 1,
|
||||||
|
});
|
||||||
|
expect(result.payload.phase).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortController.abort();
|
||||||
|
await worker;
|
||||||
|
if (jobId) {
|
||||||
|
const encodedRequester = encodeURIComponent(requesterUserId);
|
||||||
|
await clientConnector.client.del([
|
||||||
|
keys.queueKey,
|
||||||
|
`${keys.resultKeyPrefix}${encodedRequester}:${jobId}`,
|
||||||
|
`${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await clientConnector.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
|
const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: 'che:default',
|
profile: 'che:default',
|
||||||
issuedAt: now.toISOString(),
|
issuedAt: now.toISOString(),
|
||||||
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
|
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
|
||||||
sessionId: 'session',
|
sessionId: 'session',
|
||||||
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
|
user: { id: userId, username: 'tester', displayName: 'Tester', roles },
|
||||||
sanctions: {},
|
sanctions: {},
|
||||||
});
|
});
|
||||||
const city = (id: number, nationId: number) => ({
|
const city = (id: number, nationId: number) => ({
|
||||||
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
|
|||||||
meta: {},
|
meta: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
|
const context = (
|
||||||
|
options: {
|
||||||
|
me?: GeneralRow;
|
||||||
|
roles?: string[];
|
||||||
|
userId?: string;
|
||||||
|
nationMeta?: Record<string, unknown>;
|
||||||
|
nationLevel?: number;
|
||||||
|
stationCityId?: number;
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
const me = options.me ?? general();
|
const me = options.me ?? general();
|
||||||
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
|
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
|
||||||
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
|
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
|
||||||
const db = {
|
const db = {
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async () => me),
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
|
where.userId === me.userId ? me : null
|
||||||
|
),
|
||||||
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
|
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
|
||||||
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
|
if (args.where?.nationId === 1 && args.select?.cityId)
|
||||||
|
return [
|
||||||
|
{ cityId: me.cityId },
|
||||||
|
...(options.stationCityId ? [{ cityId: options.stationCityId }] : []),
|
||||||
|
];
|
||||||
if (args.where?.cityId === 2) return [foreign];
|
if (args.where?.cityId === 2) return [foreign];
|
||||||
if (args.where?.cityId === 3) return [foreign];
|
if (args.where?.cityId === 3) return [foreign];
|
||||||
if (args.where?.officerLevel) return [];
|
if (args.where?.officerLevel) return [];
|
||||||
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: '아국',
|
name: '아국',
|
||||||
color: '#008000',
|
color: '#008000',
|
||||||
level: 1,
|
level: options.nationLevel ?? 1,
|
||||||
capitalCityId: 1,
|
capitalCityId: 1,
|
||||||
meta: options.nationMeta ?? {},
|
meta: options.nationMeta ?? {},
|
||||||
})),
|
})),
|
||||||
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
|||||||
turnDaemon: {} as GameApiContext['turnDaemon'],
|
turnDaemon: {} as GameApiContext['turnDaemon'],
|
||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth: auth(options.roles),
|
auth: auth(options.roles, options.userId),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
|
|||||||
expect(result.generals).toEqual([]);
|
expect(result.generals).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives the actor from the session user instead of accepting another user general', async () => {
|
||||||
|
const caller = appRouter.createCaller(context({ userId: 'user-2' }));
|
||||||
|
await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a nation member to select a city occupied by another general of the same nation', async () => {
|
||||||
|
const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 });
|
||||||
|
expect(result.options.map((entry) => entry.id)).toContain(2);
|
||||||
|
expect(result.visibility.full).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not grant a spy city while the nation has no active level', async () => {
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 }))
|
||||||
|
.world.getCurrentCity({ cityId: 2 });
|
||||||
|
expect(result.options.map((entry) => entry.id)).not.toContain(2);
|
||||||
|
expect(result.visibility.full).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
|
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
|
||||||
const result = await appRouter
|
const result = await appRouter
|
||||||
.createCaller(context({ me: general({ cityId: 80 }) }))
|
.createCaller(context({ me: general({ cityId: 80 }) }))
|
||||||
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
|
|||||||
expect(result.visibility.full).toBe(true);
|
expect(result.visibility.full).toBe(true);
|
||||||
expect(result.city.population).toBe(1000);
|
expect(result.city.population).toBe(1000);
|
||||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
|
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
|
||||||
|
expect(result.forceSummary).toMatchObject({
|
||||||
|
enemyCrew: 777,
|
||||||
|
enemyArmedGenerals: 1,
|
||||||
|
enemyGenerals: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows administrative roles to inspect all city and general fields', async () => {
|
it.each(['admin', 'superuser', 'admin.superuser'])(
|
||||||
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
|
'allows the %s role to inspect all city and general fields',
|
||||||
expect(result.options).toHaveLength(4);
|
async (role) => {
|
||||||
expect(result.visibility.full).toBe(true);
|
const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
|
||||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
expect(result.options).toHaveLength(4);
|
||||||
});
|
expect(result.visibility.full).toBe(true);
|
||||||
|
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||||
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const now = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||||
|
id: 7,
|
||||||
|
userId: 'user-7',
|
||||||
|
name: '검증장수',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
affinity: null,
|
||||||
|
bornYear: 180,
|
||||||
|
deadYear: 300,
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 60,
|
||||||
|
intel: 50,
|
||||||
|
injury: 0,
|
||||||
|
experience: 10,
|
||||||
|
dedication: 20,
|
||||||
|
officerLevel: 1,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 1_000,
|
||||||
|
crew: 100,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 80,
|
||||||
|
atmos: 80,
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
turnTime: now,
|
||||||
|
recentWarTime: null,
|
||||||
|
age: 20,
|
||||||
|
startAge: 20,
|
||||||
|
personalCode: 'None',
|
||||||
|
specialCode: 'None',
|
||||||
|
special2Code: 'None',
|
||||||
|
lastTurn: {},
|
||||||
|
meta: {
|
||||||
|
belong: 1,
|
||||||
|
permission: 'normal',
|
||||||
|
myset: 3,
|
||||||
|
tnmt: 0,
|
||||||
|
defence_train: 80,
|
||||||
|
use_treatment: 21,
|
||||||
|
use_auto_nation_turn: 1,
|
||||||
|
},
|
||||||
|
penalty: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: now.toISOString(),
|
||||||
|
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
|
||||||
|
sessionId: 'session-7',
|
||||||
|
user: { id: 'user-7', username: 'tester', displayName: 'Tester', roles: [] },
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createContext = (options: {
|
||||||
|
me?: GeneralRow;
|
||||||
|
targets?: GeneralRow[];
|
||||||
|
nationMeta?: Record<string, unknown>;
|
||||||
|
requestCommand?: ReturnType<typeof vi.fn>;
|
||||||
|
}) => {
|
||||||
|
const me = options.me ?? buildGeneral();
|
||||||
|
const targets = options.targets ?? [me];
|
||||||
|
const requestCommand =
|
||||||
|
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id }));
|
||||||
|
const generalFindUnique = vi.fn(
|
||||||
|
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
|
||||||
|
);
|
||||||
|
const db = {
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async () => me),
|
||||||
|
findUnique: generalFindUnique,
|
||||||
|
findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
city: { findUnique: vi.fn(async () => null) },
|
||||||
|
nation: {
|
||||||
|
findUnique: vi.fn(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: '위',
|
||||||
|
color: '#777777',
|
||||||
|
level: 3,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 20_000,
|
||||||
|
tech: 100,
|
||||||
|
typeCode: 'che_법가',
|
||||||
|
capitalCityId: 1,
|
||||||
|
meta: options.nationMeta ?? { secretlimit: 3 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
logEntry: {
|
||||||
|
groupBy: vi.fn(async () => []),
|
||||||
|
findMany: vi.fn(async () => [{ id: 1, text: '기록' }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const redisClient = { get: async () => null, set: async () => null };
|
||||||
|
const context: GameApiContext = {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
redis: {} as RedisConnector['client'],
|
||||||
|
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||||
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
|
auth,
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
return { context, db, requestCommand };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('in-game my information ownership', () => {
|
||||||
|
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
|
||||||
|
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||||
|
const fixture = createContext({ requestCommand });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
const me = await caller.general.me();
|
||||||
|
expect(me?.settings).toEqual({
|
||||||
|
tnmt: 0,
|
||||||
|
defence_train: 80,
|
||||||
|
use_treatment: 21,
|
||||||
|
use_auto_nation_turn: 1,
|
||||||
|
myset: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await caller.general.setMySetting({ tnmt: 1, defence_train: 999 });
|
||||||
|
expect(requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'setMySetting',
|
||||||
|
generalId: 7,
|
||||||
|
settings: { tnmt: 1, defence_train: 999 },
|
||||||
|
});
|
||||||
|
expect(fixture.db.general.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => {
|
||||||
|
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
|
||||||
|
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
await expect(caller.general.me()).resolves.toMatchObject({
|
||||||
|
general: { id: 7, name: '검증장수' },
|
||||||
|
});
|
||||||
|
await expect(caller.general.getMyLog({ type: 'generalAction' })).resolves.toMatchObject({
|
||||||
|
type: 'generalAction',
|
||||||
|
logs: [{ id: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fixture.db.general.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId: 'user-7' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ generalId: 7 }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('battle-center general and user permissions', () => {
|
||||||
|
it('distinguishes an ordinary member, a tenured member, and an auditor', async () => {
|
||||||
|
const ordinary = createContext({
|
||||||
|
me: buildGeneral({ officerLevel: 1, meta: { belong: 1, permission: 'normal' } }),
|
||||||
|
nationMeta: { secretlimit: 3 },
|
||||||
|
});
|
||||||
|
await expect(appRouter.createCaller(ordinary.context).nation.getBattleCenter()).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenured = createContext({
|
||||||
|
me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }),
|
||||||
|
nationMeta: { secretlimit: 3 },
|
||||||
|
});
|
||||||
|
await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({
|
||||||
|
me: { id: 7, permissionLevel: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const auditor = createContext({
|
||||||
|
me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }),
|
||||||
|
nationMeta: { secretlimit: 3 },
|
||||||
|
});
|
||||||
|
await expect(appRouter.createCaller(auditor.context).nation.getBattleCenter()).resolves.toMatchObject({
|
||||||
|
me: { id: 7, permissionLevel: 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts another user action log while allowing own, NPC, chief, and non-private logs', async () => {
|
||||||
|
const me = buildGeneral({ meta: { belong: 3, permission: 'normal' } });
|
||||||
|
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저', npcState: 0 });
|
||||||
|
const npc = buildGeneral({ id: 9, userId: null, name: 'NPC', npcState: 2 });
|
||||||
|
const foreign = buildGeneral({ id: 10, userId: 'user-10', name: '타국', nationId: 2 });
|
||||||
|
const memberFixture = createContext({
|
||||||
|
me,
|
||||||
|
targets: [me, otherUser, npc, foreign],
|
||||||
|
nationMeta: { secretlimit: 3 },
|
||||||
|
});
|
||||||
|
const member = appRouter.createCaller(memberFixture.context);
|
||||||
|
|
||||||
|
await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({
|
||||||
|
generalId: me.id,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
await expect(
|
||||||
|
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'battleDetail' })
|
||||||
|
).resolves.toMatchObject({ generalId: otherUser.id });
|
||||||
|
await expect(member.nation.getGeneralLog({ generalId: npc.id, type: 'generalAction' })).resolves.toMatchObject({
|
||||||
|
generalId: npc.id,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
member.nation.getGeneralLog({ generalId: foreign.id, type: 'battleDetail' })
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
|
||||||
|
const chiefFixture = createContext({
|
||||||
|
me: buildGeneral({ officerLevel: 5 }),
|
||||||
|
targets: [buildGeneral({ officerLevel: 5 }), otherUser],
|
||||||
|
nationMeta: { secretlimit: 3 },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(chiefFixture.context)
|
||||||
|
.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
|
||||||
|
).resolves.toMatchObject({ generalId: otherUser.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||||
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||||
|
id: 7,
|
||||||
|
userId: 'user-1',
|
||||||
|
name: '유비',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
affinity: null,
|
||||||
|
bornYear: 180,
|
||||||
|
deadYear: 300,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
injury: 0,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
turnTime: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
age: 20,
|
||||||
|
startAge: 20,
|
||||||
|
personalCode: 'None',
|
||||||
|
specialCode: 'None',
|
||||||
|
special2Code: 'che_선봉',
|
||||||
|
lastTurn: {},
|
||||||
|
meta: {},
|
||||||
|
penalty: {},
|
||||||
|
createdAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
sessionId: `session-${userId}`,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
username: userId,
|
||||||
|
displayName: userId,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const worldState = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 3600,
|
||||||
|
config: {
|
||||||
|
const: {
|
||||||
|
availableSpecialWar: ['che_선봉'],
|
||||||
|
allItems: {
|
||||||
|
weapon: {
|
||||||
|
che_무기_12_칠성검: 1,
|
||||||
|
che_무기_01_단도: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
|
||||||
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = (options: {
|
||||||
|
auth?: GameSessionTokenPayload | null;
|
||||||
|
general?: GeneralRow | null;
|
||||||
|
target?: GeneralRow | null;
|
||||||
|
inheritancePoint?: number;
|
||||||
|
}) => {
|
||||||
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
|
const target =
|
||||||
|
options.target === undefined
|
||||||
|
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
|
||||||
|
: options.target;
|
||||||
|
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
|
||||||
|
type: command.type,
|
||||||
|
ok: true,
|
||||||
|
generalId: command.generalId,
|
||||||
|
}));
|
||||||
|
const pointUpsert = vi.fn(async () => ({}));
|
||||||
|
const logCreate = vi.fn(async () => ({}));
|
||||||
|
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||||
|
const db = {
|
||||||
|
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => worldState),
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
|
general?.userId === where.userId ? general : null
|
||||||
|
),
|
||||||
|
findMany,
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
|
target?.id === where.id ? target : null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
inheritancePoint: {
|
||||||
|
upsert: pointUpsert,
|
||||||
|
},
|
||||||
|
inheritanceLog: {
|
||||||
|
create: logCreate,
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
inheritanceUserState: {
|
||||||
|
findUnique: vi.fn(async () => null),
|
||||||
|
upsert: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
|
{
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
},
|
||||||
|
'che:default'
|
||||||
|
);
|
||||||
|
const context: GameApiContext = {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
redis: {} as RedisConnector['client'],
|
||||||
|
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||||
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
|
auth,
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
accessTokenStore,
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
return { context, requestCommand, pointUpsert, logCreate, findMany };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('inherit router actor and permission boundaries', () => {
|
||||||
|
it('rejects unauthenticated status and mutations', async () => {
|
||||||
|
const fixture = buildContext({ auth: null });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
|
||||||
|
const fixture = buildContext({});
|
||||||
|
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
|
||||||
|
|
||||||
|
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
|
||||||
|
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
|
||||||
|
expect(status.availableUnique).toEqual([
|
||||||
|
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
|
||||||
|
]);
|
||||||
|
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
|
||||||
|
expect(fixture.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
||||||
|
const fixture = buildContext({
|
||||||
|
auth: buildAuth('user-2'),
|
||||||
|
general: buildGeneral({ userId: 'user-1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||||
|
type: 'domesticSuccessProb',
|
||||||
|
level: 1,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '장수가 존재하지 않습니다.',
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mutates only the authenticated user general and inheritance balance', async () => {
|
||||||
|
const fixture = buildContext({ inheritancePoint: 1000 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||||
|
type: 'domesticSuccessProb',
|
||||||
|
level: 1,
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ ok: true, remainPoint: 800 });
|
||||||
|
|
||||||
|
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'patchGeneral',
|
||||||
|
generalId: 7,
|
||||||
|
patch: expect.objectContaining({
|
||||||
|
meta: expect.objectContaining({
|
||||||
|
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||||
|
update: { value: 800 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||||
|
const fixture = buildContext({ inheritancePoint: 1500 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
ownerName: '위유저',
|
||||||
|
targetName: '조조',
|
||||||
|
});
|
||||||
|
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||||
|
update: { value: 500 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: 'user-1',
|
||||||
|
year: 200,
|
||||||
|
month: 4,
|
||||||
|
text: '1000 포인트로 장수 소유자 확인',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = {
|
|||||||
sanctions: {},
|
sanctions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildContext = (overrides: Record<string, unknown> = {}) => {
|
const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
|
||||||
const executeRaw = vi.fn(async () => 1);
|
const executeRaw = vi.fn(async () => 1);
|
||||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
const db = {
|
const db = {
|
||||||
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
|||||||
$executeRaw: executeRaw,
|
$executeRaw: executeRaw,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
const redis = {
|
||||||
|
set: vi.fn(async () => 'OK'),
|
||||||
|
publish: vi.fn(async () => 1),
|
||||||
|
};
|
||||||
const context = {
|
const context = {
|
||||||
db,
|
db,
|
||||||
auth,
|
auth,
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
redis: {},
|
redis,
|
||||||
turnDaemon: {},
|
turnDaemon: {},
|
||||||
battleSim: {},
|
battleSim: {},
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
|||||||
accessTokenStore: {},
|
accessTokenStore: {},
|
||||||
flushStore: {},
|
flushStore: {},
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
|
...contextOverrides,
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
|
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('messages router missing-flow compatibility', () => {
|
describe('messages router missing-flow compatibility', () => {
|
||||||
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(result.canRespondDiplomacy).toBe(true);
|
expect(result.canRespondDiplomacy).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => {
|
||||||
|
const ambassador = {
|
||||||
|
...general,
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { permission: 'ambassador' },
|
||||||
|
} as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => ambassador),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.messages.getRecent({ generalId: ambassador.id });
|
||||||
|
|
||||||
|
expect(result.permission).toBe(4);
|
||||||
|
expect(result.canRespondDiplomacy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts recent and old diplomacy content below secret permission 3', async () => {
|
||||||
|
const diplomacyRow = {
|
||||||
|
id: 19,
|
||||||
|
mailbox: 9001,
|
||||||
|
type: 'diplomacy',
|
||||||
|
src: 9002,
|
||||||
|
dest: 9001,
|
||||||
|
time: new Date(),
|
||||||
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
|
message: {
|
||||||
|
src: {
|
||||||
|
generalId: 8,
|
||||||
|
generalName: '외교관',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '위',
|
||||||
|
color: '#ffffff',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '보이면 안 되는 외교 본문',
|
||||||
|
option: { action: 'noAggression' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
||||||
|
const { caller } = buildContext({
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recent = await caller.messages.getRecent({ generalId: general.id });
|
||||||
|
const old = await caller.messages.getOld({
|
||||||
|
generalId: general.id,
|
||||||
|
type: 'diplomacy',
|
||||||
|
to: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recent.permission).toBe(2);
|
||||||
|
expect(recent.diplomacy[0]).toMatchObject({
|
||||||
|
text: '(외교 메시지입니다)',
|
||||||
|
option: { action: 'noAggression', invalid: true },
|
||||||
|
});
|
||||||
|
expect(old.diplomacy[0]).toMatchObject({
|
||||||
|
text: '(외교 메시지입니다)',
|
||||||
|
option: { action: 'noAggression', invalid: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||||
|
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
||||||
|
const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||||
|
id: where.id,
|
||||||
|
name: where.id === 1 ? '위' : '촉',
|
||||||
|
color: '#112233',
|
||||||
|
meta: {},
|
||||||
|
}));
|
||||||
|
const { caller } = buildContext({
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: findNation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.messages.send({
|
||||||
|
generalId: general.id,
|
||||||
|
mailbox: 9002,
|
||||||
|
text: '국가 메시지',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.msgType).toBe('national');
|
||||||
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||||
|
const ambassador = {
|
||||||
|
...general,
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { permission: 'ambassador' },
|
||||||
|
} as GeneralRow;
|
||||||
|
const queryRaw = vi.fn(async () => [{ id: 52 }]);
|
||||||
|
const { caller } = buildContext({
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => ambassador),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||||
|
id: where.id,
|
||||||
|
name: where.id === 1 ? '위' : '촉',
|
||||||
|
color: '#112233',
|
||||||
|
meta: {},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.messages.send({
|
||||||
|
generalId: ambassador.id,
|
||||||
|
mailbox: 9002,
|
||||||
|
text: '외교 메시지',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.msgType).toBe('diplomacy');
|
||||||
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks private messages between foreign ambassadors', async () => {
|
||||||
|
const ambassador = {
|
||||||
|
...general,
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { permission: 'ambassador' },
|
||||||
|
} as GeneralRow;
|
||||||
|
const foreignAmbassador = {
|
||||||
|
...ambassador,
|
||||||
|
id: 8,
|
||||||
|
userId: 'user-8',
|
||||||
|
name: '상대 외교관',
|
||||||
|
nationId: 2,
|
||||||
|
} as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
|
where.id === ambassador.id ? ambassador : foreignAmbassador
|
||||||
|
),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||||
|
id: where.id,
|
||||||
|
name: where.id === 1 ? '위' : '촉',
|
||||||
|
color: '#112233',
|
||||||
|
meta: {},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: ambassador.id,
|
||||||
|
mailbox: foreignAmbassador.id,
|
||||||
|
text: '개인 메시지',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'],
|
||||||
|
['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'],
|
||||||
|
])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => {
|
||||||
|
const penalized = { ...general, penalty } as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => penalized),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: penalized.id,
|
||||||
|
mailbox,
|
||||||
|
text: '차단 메시지',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN', message });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => {
|
||||||
|
const redis = {
|
||||||
|
set: vi.fn(async () => null),
|
||||||
|
publish: vi.fn(async () => 1),
|
||||||
|
};
|
||||||
|
const { caller } = buildContext(
|
||||||
|
{
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: general.id,
|
||||||
|
mailbox: 8,
|
||||||
|
text: '너무 빠른 메시지',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: '개인메세지는 2초당 1건만 보낼 수 있습니다!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks sends for a muted authenticated user independently of general permission', async () => {
|
||||||
|
const mutedAuth = {
|
||||||
|
...auth,
|
||||||
|
sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' },
|
||||||
|
};
|
||||||
|
const { caller } = buildContext({}, { auth: mutedAuth });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: general.id,
|
||||||
|
mailbox: 9999,
|
||||||
|
text: '사용자 mute',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '메시지 전송이 제한된 계정입니다.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects every remaining general-scoped message mutation for another user general', async () => {
|
||||||
|
const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => foreignGeneral),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
caller.messages.readLatest({
|
||||||
|
generalId: foreignGeneral.id,
|
||||||
|
type: 'private',
|
||||||
|
messageId: 1,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
caller.messages.respond({
|
||||||
|
generalId: foreignGeneral.id,
|
||||||
|
messageId: 1,
|
||||||
|
response: true,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
});
|
||||||
|
|
||||||
it('persists latest-read updates through the monotonic upsert', async () => {
|
it('persists latest-read updates through the monotonic upsert', async () => {
|
||||||
const { caller, executeRaw } = buildContext();
|
const { caller, executeRaw } = buildContext();
|
||||||
|
|
||||||
@@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||||
|
const queryRaw = vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 25,
|
||||||
|
mailbox: 9001,
|
||||||
|
type: 'diplomacy',
|
||||||
|
src: 9001,
|
||||||
|
dest: 9002,
|
||||||
|
time: new Date(),
|
||||||
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
|
message: {
|
||||||
|
src: {
|
||||||
|
generalId: general.id,
|
||||||
|
generalName: general.name,
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '위',
|
||||||
|
color: '#fff',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '일반 외교 메시지',
|
||||||
|
option: { receiverMessageID: 26 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||||
|
|
||||||
|
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
||||||
|
|
||||||
|
expect(result.deletedIds).toEqual([25]);
|
||||||
|
expect(updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { in: [25] } },
|
||||||
|
data: { validUntil: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects deleting another general message', async () => {
|
it('rejects deleting another general message', async () => {
|
||||||
const queryRaw = vi.fn(async () => [
|
const queryRaw = vi.fn(async () => [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl);
|
|||||||
const bettingId = 990_071;
|
const bettingId = 990_071;
|
||||||
const concurrentBettingId = 990_072;
|
const concurrentBettingId = 990_072;
|
||||||
const generalId = 9_971;
|
const generalId = 9_971;
|
||||||
|
const otherGeneralId = 9_972;
|
||||||
const nationId = 990_071;
|
const nationId = 990_071;
|
||||||
|
const otherNationId = 990_072;
|
||||||
const userId = 'nation-betting-router-user';
|
const userId = 'nation-betting-router-user';
|
||||||
|
const otherUserId = 'nation-betting-router-other-user';
|
||||||
|
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||||
|
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
|
|||||||
sanctions: {},
|
sanctions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const otherAuth: GameSessionTokenPayload = {
|
||||||
|
...auth,
|
||||||
|
sessionId: 'nation-betting-router-other-session',
|
||||||
|
user: {
|
||||||
|
...auth.user,
|
||||||
|
id: otherUserId,
|
||||||
|
username: 'other-bettor',
|
||||||
|
displayName: 'Other Bettor',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const noGeneralAuth: GameSessionTokenPayload = {
|
||||||
|
...auth,
|
||||||
|
sessionId: 'nation-betting-router-no-general-session',
|
||||||
|
user: {
|
||||||
|
...auth.user,
|
||||||
|
id: noGeneralUserId,
|
||||||
|
username: 'no-general',
|
||||||
|
displayName: 'No General',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
integration('nation betting router', () => {
|
integration('nation betting router', () => {
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
let worldStateId: number;
|
let worldStateId: number;
|
||||||
|
|
||||||
const buildContext = (requestId: string): GameApiContext => {
|
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
|
||||||
const redisClient = {
|
const redisClient = {
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
set: async () => null,
|
set: async () => null,
|
||||||
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
|
|||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
auth,
|
auth: actorAuth,
|
||||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
closeDb = () => connector.disconnect();
|
closeDb = () => connector.disconnect();
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId } });
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.general.deleteMany({ where: { id: generalId } });
|
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.nation.deleteMany({ where: { id: nationId } });
|
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
|
|
||||||
await db.nation.create({
|
await db.nation.createMany({
|
||||||
data: {
|
data: [
|
||||||
id: nationId,
|
{
|
||||||
name: '베팅국',
|
id: nationId,
|
||||||
color: '#123456',
|
name: '베팅국',
|
||||||
level: 2,
|
color: '#123456',
|
||||||
},
|
level: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherNationId,
|
||||||
|
name: '다른베팅국',
|
||||||
|
color: '#654321',
|
||||||
|
level: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await db.general.create({
|
await db.general.createMany({
|
||||||
data: {
|
data: [
|
||||||
id: generalId,
|
{
|
||||||
userId,
|
id: generalId,
|
||||||
name: '베팅장수',
|
userId,
|
||||||
nationId,
|
name: '베팅장수',
|
||||||
cityId: 1,
|
nationId,
|
||||||
npcState: 0,
|
cityId: 1,
|
||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
npcState: 0,
|
||||||
meta: {},
|
officerLevel: 0,
|
||||||
},
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherGeneralId,
|
||||||
|
userId: otherUserId,
|
||||||
|
name: '다른국가수뇌',
|
||||||
|
nationId: otherNationId,
|
||||||
|
cityId: 1,
|
||||||
|
npcState: 0,
|
||||||
|
officerLevel: 12,
|
||||||
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
const world = await db.worldState.create({
|
const world = await db.worldState.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
|
|||||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.inheritancePoint.create({
|
await db.inheritancePoint.createMany({
|
||||||
data: { userId, key: 'previous', value: 1_000 },
|
data: [
|
||||||
|
{ userId, key: 'previous', value: 1_000 },
|
||||||
|
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId } });
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.general.deleteMany({ where: { id: generalId } });
|
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.nation.deleteMany({ where: { id: nationId } });
|
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
await db.worldState.delete({ where: { id: worldStateId } });
|
await db.worldState.delete({ where: { id: worldStateId } });
|
||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
});
|
});
|
||||||
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
||||||
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
|
expect(
|
||||||
.toMatchObject({ _sum: { amount: 600 } });
|
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
|
||||||
|
).toMatchObject({ _sum: { amount: 600 } });
|
||||||
expect(
|
expect(
|
||||||
await db.inheritancePoint.findUniqueOrThrow({
|
await db.inheritancePoint.findUniqueOrThrow({
|
||||||
where: { userId_key: { userId, key: 'previous' } },
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
})
|
})
|
||||||
).toMatchObject({ value: 250 });
|
).toMatchObject({ value: 250 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
||||||
|
req: 'bettingNation',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext('nation-betting-anonymous-detail', null))
|
||||||
|
.betting.getDetail({ bettingId })
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({
|
||||||
|
bettingId,
|
||||||
|
bettingType: [0],
|
||||||
|
amount: 10,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({
|
||||||
|
req: 'bettingNation',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth))
|
||||||
|
.betting.getDetail({ bettingId })
|
||||||
|
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({
|
||||||
|
bettingId,
|
||||||
|
bettingType: [0],
|
||||||
|
amount: 10,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows generals across nation and office levels while isolating each session user bet', async () => {
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({
|
||||||
|
req: 'bettingNation',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
result: true,
|
||||||
|
bettingList: {
|
||||||
|
[bettingId]: { name: '천통국 예상' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({
|
||||||
|
bettingId,
|
||||||
|
bettingType: [0],
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ result: true });
|
||||||
|
|
||||||
|
const [firstUserDetail, otherUserDetail] = await Promise.all([
|
||||||
|
appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }),
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext('nation-betting-other-user-detail', otherAuth))
|
||||||
|
.betting.getDetail({ bettingId }),
|
||||||
|
]);
|
||||||
|
expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]);
|
||||||
|
expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]);
|
||||||
|
expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||||
|
expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.nationBet.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
bettingId_userId_selectionKey: {
|
||||||
|
bettingId,
|
||||||
|
userId: otherUserId,
|
||||||
|
selectionKey: '[0]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
generalId: otherGeneralId,
|
||||||
|
userId: otherUserId,
|
||||||
|
amount: 100,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await db.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId: otherUserId, key: 'previous' } },
|
||||||
|
})
|
||||||
|
).toMatchObject({ value: 400 });
|
||||||
|
expect(
|
||||||
|
await db.rankData.findUniqueOrThrow({
|
||||||
|
where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } },
|
||||||
|
})
|
||||||
|
).toMatchObject({ nationId: otherNationId, value: 100 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const now = new Date('2026-01-01T01:02:00Z');
|
||||||
|
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||||
|
id: 1,
|
||||||
|
userId: 'u1',
|
||||||
|
name: '장수',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
affinity: null,
|
||||||
|
bornYear: 180,
|
||||||
|
deadYear: 300,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 60,
|
||||||
|
intel: 50,
|
||||||
|
injury: 0,
|
||||||
|
experience: 900,
|
||||||
|
dedication: 100,
|
||||||
|
officerLevel: 1,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 2000,
|
||||||
|
crew: 300,
|
||||||
|
crewTypeId: 1,
|
||||||
|
train: 90,
|
||||||
|
atmos: 90,
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
turnTime: now,
|
||||||
|
recentWarTime: null,
|
||||||
|
age: 20,
|
||||||
|
startAge: 20,
|
||||||
|
personalCode: 'None',
|
||||||
|
specialCode: 'None',
|
||||||
|
special2Code: 'None',
|
||||||
|
lastTurn: {},
|
||||||
|
meta: { belong: 1, defence_train: 80, killturn: 7 },
|
||||||
|
penalty: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const token = (userId: string): GameSessionTokenPayload => ({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: now.toISOString(),
|
||||||
|
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
|
||||||
|
sessionId: userId,
|
||||||
|
user: { id: userId, username: userId, displayName: userId, roles: [] },
|
||||||
|
sanctions: {},
|
||||||
|
});
|
||||||
|
const fixture = (generals: GeneralRow[], userId = 'u1') => {
|
||||||
|
const db = {
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
|
generals.find((g) => g.userId === where.userId)
|
||||||
|
),
|
||||||
|
findMany: vi.fn(async ({ where }: { where: { nationId: number } }) =>
|
||||||
|
generals.filter((g) => g.nationId === where.nationId)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findUnique: vi.fn(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: '위',
|
||||||
|
color: '#080',
|
||||||
|
level: 3,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
capitalCityId: 1,
|
||||||
|
meta: { secretlimit: 3 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) },
|
||||||
|
troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) },
|
||||||
|
worldState: { findFirst: vi.fn(async () => null) },
|
||||||
|
generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) },
|
||||||
|
generalAccessLog: {
|
||||||
|
findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
|
||||||
|
const context: GameApiContext = {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
redis,
|
||||||
|
turnDaemon: {} as GameApiContext['turnDaemon'],
|
||||||
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
|
auth: token(userId),
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'secret',
|
||||||
|
};
|
||||||
|
return { caller: appRouter.createCaller(context), db };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('nation general and secret office permissions', () => {
|
||||||
|
it('redacts ordinary-member details and denies the secret office', async () => {
|
||||||
|
const { caller } = fixture([general()]);
|
||||||
|
const result = await caller.nation.getGeneralList();
|
||||||
|
expect(result.viewer).toEqual({ generalId: 1, permission: 0 });
|
||||||
|
expect(result.generals[0]).toMatchObject({
|
||||||
|
officerLevel: 1,
|
||||||
|
cityName: null,
|
||||||
|
troopName: null,
|
||||||
|
refreshScoreTotal: 10,
|
||||||
|
});
|
||||||
|
expect(result.generals[0]).not.toHaveProperty('crew');
|
||||||
|
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
});
|
||||||
|
it('uses the session-owned general and scopes secret rows to that nation', async () => {
|
||||||
|
const first = general();
|
||||||
|
const actor = general({ id: 2, userId: 'u2', officerLevel: 5, meta: { belong: 1 } });
|
||||||
|
const ally = general({ id: 3, userId: 'u3', gold: 3000, crew: 200, train: 80, atmos: 80 });
|
||||||
|
const foreign = general({ id: 4, userId: 'u4', nationId: 2, gold: 99999 });
|
||||||
|
const { caller, db } = fixture([first, actor, ally, foreign], 'u2');
|
||||||
|
const result = await caller.nation.getSecretGeneralList();
|
||||||
|
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
||||||
|
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
||||||
|
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
||||||
|
expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } });
|
||||||
|
expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } }));
|
||||||
|
});
|
||||||
|
it('honors general penalties after a user switch', async () => {
|
||||||
|
const penalized = general({ officerLevel: 5, penalty: { noChief: true } });
|
||||||
|
const { caller } = fixture([penalized]);
|
||||||
|
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,248 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
||||||
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const profile: GameProfile = {
|
||||||
|
id: 'che',
|
||||||
|
scenario: 'default',
|
||||||
|
name: 'che:default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: 'che',
|
||||||
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
sessionId: 'ranking-session',
|
||||||
|
user: {
|
||||||
|
id: 'request-user-id',
|
||||||
|
username: 'ranking-user',
|
||||||
|
displayName: '조회자',
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalRows = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '유비',
|
||||||
|
nationId: 1,
|
||||||
|
userId: 'private-user-id-1',
|
||||||
|
npcState: 0,
|
||||||
|
picture: '1.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
meta: { ownerName: '공개소유자' },
|
||||||
|
experience: 1200,
|
||||||
|
dedication: 900,
|
||||||
|
horseCode: 'che_명마_15_적토마',
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '빙의관우',
|
||||||
|
nationId: 1,
|
||||||
|
userId: 'private-user-id-2',
|
||||||
|
npcState: 1,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
meta: { owner_name: '빙의소유자' },
|
||||||
|
experience: 1100,
|
||||||
|
dedication: 800,
|
||||||
|
horseCode: 'None',
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'NPC조조',
|
||||||
|
nationId: 2,
|
||||||
|
userId: null,
|
||||||
|
npcState: 2,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
meta: {},
|
||||||
|
experience: 1300,
|
||||||
|
dedication: 1000,
|
||||||
|
horseCode: 'None',
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const buildContext = (options?: {
|
||||||
|
authenticated?: boolean;
|
||||||
|
isUnited?: boolean;
|
||||||
|
includeOwnerDisplayName?: boolean;
|
||||||
|
}): GameApiContext => {
|
||||||
|
const db = {
|
||||||
|
worldState: {
|
||||||
|
findFirst: async () => ({
|
||||||
|
meta: { isUnited: options?.isUnited ? 1 : 0 },
|
||||||
|
config: {
|
||||||
|
const: {
|
||||||
|
allItems: {
|
||||||
|
horse: { che_명마_15_적토마: 2 },
|
||||||
|
weapon: {},
|
||||||
|
book: {},
|
||||||
|
item: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: async () => [
|
||||||
|
{ id: 1, name: '촉', color: '#006400' },
|
||||||
|
{ id: 2, name: '위', color: '#8b0000' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) =>
|
||||||
|
generalRows.filter((general) =>
|
||||||
|
args.where.npcState.gte !== undefined
|
||||||
|
? general.npcState >= args.where.npcState.gte
|
||||||
|
: general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
rankData: {
|
||||||
|
findMany: async () => [
|
||||||
|
{ generalId: 1, type: 'firenum', value: 10 },
|
||||||
|
{ generalId: 2, type: 'firenum', value: 20 },
|
||||||
|
{ generalId: 3, type: 'firenum', value: 30 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
auction: {
|
||||||
|
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
|
||||||
|
},
|
||||||
|
gameHistory: {
|
||||||
|
findMany: async () => [
|
||||||
|
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||||
|
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hallOfFame: {
|
||||||
|
findMany: async (args: { where: { type: string } }) =>
|
||||||
|
args.where.type === 'experience'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
generalNo: 1,
|
||||||
|
value: 1200,
|
||||||
|
aux: {
|
||||||
|
name: '유비',
|
||||||
|
ownerName: 'private-hall-user-id',
|
||||||
|
...(options?.includeOwnerDisplayName ? { ownerDisplayName: '공개소유자' } : {}),
|
||||||
|
nationName: '촉',
|
||||||
|
bgColor: '#006400',
|
||||||
|
fgColor: '#ffffff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
} as unknown as RedisConnector['client'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
|
battleSim: new InMemoryBattleSimTransport(),
|
||||||
|
profile,
|
||||||
|
auth: options?.authenticated === false ? null : auth,
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
redis,
|
||||||
|
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ranking.getBestGeneral', () => {
|
||||||
|
it('requires a game login even though the ranking is the same for every authenticated user', async () => {
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext({ authenticated: false })).ranking.getBestGeneral({ view: 'user' })
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps possessed generals in the user view and redacts account identifiers before unification', async () => {
|
||||||
|
const result = await appRouter.createCaller(buildContext({ isUnited: false })).ranking.getBestGeneral({
|
||||||
|
view: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([1, 2]);
|
||||||
|
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual([null, null]);
|
||||||
|
expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries).toEqual([
|
||||||
|
expect.objectContaining({ id: 2, name: '???', nationName: '???', ownerName: null }),
|
||||||
|
expect.objectContaining({ id: 1, name: '???', nationName: '???', ownerName: null }),
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private-user-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses display names only after unification and preserves configured item copies plus auctions', async () => {
|
||||||
|
const result = await appRouter.createCaller(buildContext({ isUnited: true })).ranking.getBestGeneral({
|
||||||
|
view: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual(['공개소유자', '빙의소유자']);
|
||||||
|
expect(result.uniqueItems.find((section) => section.slot === 'horse')?.entries).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
itemKey: 'che_명마_15_적토마',
|
||||||
|
owner: expect.objectContaining({ id: 1, name: '유비' }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
itemKey: 'che_명마_15_적토마',
|
||||||
|
owner: expect.objectContaining({ id: 0, name: '경매중' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private-user-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates autonomous NPCs from users and possessed generals', async () => {
|
||||||
|
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
|
||||||
|
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ranking hall of fame', () => {
|
||||||
|
it('remains public and groups scenario counts', async () => {
|
||||||
|
const options = await appRouter
|
||||||
|
.createCaller(buildContext({ authenticated: false }))
|
||||||
|
.ranking.getHallOfFameOptions();
|
||||||
|
expect(options).toEqual([
|
||||||
|
{
|
||||||
|
season: 3,
|
||||||
|
scenarios: [{ id: 22, name: '가상모드22', count: 2 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an explicit display name but never exposes the stored account identifier', async () => {
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
|
||||||
|
.ranking.getHallOfFame({ season: 3 });
|
||||||
|
expect(result.sections[0]?.entries[0]?.ownerName).toBe('공개소유자');
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private-hall-user-id');
|
||||||
|
|
||||||
|
const redacted = await appRouter
|
||||||
|
.createCaller(buildContext({ authenticated: false }))
|
||||||
|
.ranking.getHallOfFame({ season: 3 });
|
||||||
|
expect(redacted.sections[0]?.entries[0]?.ownerName).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
TournamentState,
|
TournamentState,
|
||||||
} from '../src/tournament/types.js';
|
} from '../src/tournament/types.js';
|
||||||
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
|
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
|
||||||
|
import { buildBettingPayouts } from '../src/tournament/workerHelpers.js';
|
||||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||||
|
|
||||||
class MemoryRedis {
|
class MemoryRedis {
|
||||||
@@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: {
|
|||||||
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
describe('tournament worker (in-memory)', () => {
|
describe('tournament worker (in-memory)', () => {
|
||||||
|
it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => {
|
||||||
|
expect(
|
||||||
|
buildBettingPayouts(10, [
|
||||||
|
{ generalId: 1, targetId: 11, amount: 100 },
|
||||||
|
{ generalId: 2, targetId: 12, amount: 200 },
|
||||||
|
])
|
||||||
|
).toEqual({ payouts: [], total: 300, refundAll: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('locks 64 applicants into eight groups of eight', async () => {
|
it('locks 64 applicants into eight groups of eight', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
|
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
|
||||||
@@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
|
|
||||||
const sent: TurnDaemonCommand[] = [];
|
const sent: TurnDaemonCommand[] = [];
|
||||||
const transport: TurnDaemonTransport = {
|
const transport: TurnDaemonTransport = {
|
||||||
sendCommand: async (command) => {
|
sendCommand: async () => 'unused',
|
||||||
|
requestCommand: async (command) => {
|
||||||
sent.push(command);
|
sent.push(command);
|
||||||
return 'ok';
|
if (command.type === 'tournamentReward') {
|
||||||
|
return {
|
||||||
|
type: 'tournamentReward',
|
||||||
|
ok: true,
|
||||||
|
winnerId: command.winnerId,
|
||||||
|
runnerUpId: command.runnerUpId,
|
||||||
|
rewarded: 2,
|
||||||
|
missing: 0,
|
||||||
|
totalGold: 100,
|
||||||
|
totalExp: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.type === 'tournamentBettingPayout') {
|
||||||
|
return {
|
||||||
|
type: 'tournamentBettingPayout',
|
||||||
|
ok: true,
|
||||||
|
bettingId: command.bettingId,
|
||||||
|
processed: command.payouts.length,
|
||||||
|
missing: 0,
|
||||||
|
totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
requestCommand: async () => null,
|
|
||||||
requestStatus: async () => null,
|
requestStatus: async () => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
|
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
|
||||||
expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]);
|
expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]);
|
||||||
}
|
}
|
||||||
|
expect(await store.getState()).toMatchObject({
|
||||||
|
rewardSettled: true,
|
||||||
|
bettingSettled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => {
|
||||||
|
const redis = new MemoryRedis();
|
||||||
|
const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry'));
|
||||||
|
const state = createTournamentState({
|
||||||
|
stage: 0,
|
||||||
|
auto: false,
|
||||||
|
winnerId: 10,
|
||||||
|
bettingId: 124,
|
||||||
|
rewardSettled: false,
|
||||||
|
bettingSettled: false,
|
||||||
|
});
|
||||||
|
await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]);
|
||||||
|
await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]);
|
||||||
|
await store.setState(state);
|
||||||
|
|
||||||
|
let payoutAttempts = 0;
|
||||||
|
const commands: TurnDaemonCommand[] = [];
|
||||||
|
const transport: TurnDaemonTransport = {
|
||||||
|
sendCommand: async () => 'unused',
|
||||||
|
requestCommand: async (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command.type === 'tournamentReward') {
|
||||||
|
return {
|
||||||
|
type: 'tournamentReward',
|
||||||
|
ok: true,
|
||||||
|
winnerId: command.winnerId,
|
||||||
|
runnerUpId: command.runnerUpId,
|
||||||
|
rewarded: 2,
|
||||||
|
missing: 0,
|
||||||
|
totalGold: 100,
|
||||||
|
totalExp: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.type === 'tournamentBettingPayout') {
|
||||||
|
payoutAttempts += 1;
|
||||||
|
if (payoutAttempts === 1) {
|
||||||
|
return {
|
||||||
|
type: 'tournamentBettingPayout',
|
||||||
|
ok: false,
|
||||||
|
bettingId: command.bettingId,
|
||||||
|
reason: '일시적 실패',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'tournamentBettingPayout',
|
||||||
|
ok: true,
|
||||||
|
bettingId: command.bettingId,
|
||||||
|
processed: 1,
|
||||||
|
missing: 0,
|
||||||
|
totalPayout: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
requestStatus: async () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow(
|
||||||
|
'일시적 실패'
|
||||||
|
);
|
||||||
|
const afterFailure = await store.getState();
|
||||||
|
expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false });
|
||||||
|
|
||||||
|
await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! });
|
||||||
|
expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true });
|
||||||
|
expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1);
|
||||||
|
expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId)
|
||||||
|
).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => {
|
it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10;
|
|||||||
const DEFAULT_MAX_TECH_LEVEL = 12;
|
const DEFAULT_MAX_TECH_LEVEL = 12;
|
||||||
const DEFAULT_BASE_GOLD = 0;
|
const DEFAULT_BASE_GOLD = 0;
|
||||||
const DEFAULT_BASE_RICE = 2000;
|
const DEFAULT_BASE_RICE = 2000;
|
||||||
|
const DEFAULT_GENERAL_MINIMUM_GOLD = 0;
|
||||||
|
const DEFAULT_GENERAL_MINIMUM_RICE = 500;
|
||||||
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
|
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
|
||||||
|
|
||||||
const normalizeCode = (value: string | null | undefined): string | null => {
|
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||||
@@ -132,6 +134,8 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
||||||
|
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD),
|
||||||
|
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE),
|
||||||
maxResourceActionAmount: resolveNumber(
|
maxResourceActionAmount: resolveNumber(
|
||||||
constValues,
|
constValues,
|
||||||
['maxResourceActionAmount'],
|
['maxResourceActionAmount'],
|
||||||
|
|||||||
@@ -809,9 +809,35 @@ async function handleVacation(
|
|||||||
if (!general) {
|
if (!general) {
|
||||||
return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||||
}
|
}
|
||||||
|
const autorunUser = asRecord(world.getState().meta.autorun_user);
|
||||||
|
if (autorunUser.limit_minutes) {
|
||||||
|
return {
|
||||||
|
type: 'vacation',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0);
|
||||||
|
world.updateGeneral(general.id, {
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
killturn: killturn * 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
return { type: 'vacation', ok: true, generalId: command.generalId };
|
return { type: 'vacation', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeDefenceTrain = (value: number): number => {
|
||||||
|
if (value <= 40) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
if (value <= 90) {
|
||||||
|
return Math.round(value / 10) * 10;
|
||||||
|
}
|
||||||
|
return 999;
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSetMySetting(
|
async function handleSetMySetting(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
|
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
|
||||||
@@ -826,11 +852,48 @@ async function handleSetMySetting(
|
|||||||
reason: '장수 정보를 찾을 수 없습니다.',
|
reason: '장수 정보를 찾을 수 없습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settings = command.settings;
|
||||||
|
const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80);
|
||||||
|
const nextDefenceTrain =
|
||||||
|
settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train);
|
||||||
|
const nextMeta = { ...general.meta };
|
||||||
|
|
||||||
|
if (settings.tnmt !== undefined) {
|
||||||
|
nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt;
|
||||||
|
}
|
||||||
|
if (settings.use_treatment !== undefined) {
|
||||||
|
nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment));
|
||||||
|
}
|
||||||
|
if (settings.use_auto_nation_turn !== undefined) {
|
||||||
|
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextTrain = general.train;
|
||||||
|
let nextAtmos = general.atmos;
|
||||||
|
if (nextDefenceTrain !== previousDefenceTrain) {
|
||||||
|
nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1;
|
||||||
|
nextMeta.defence_train = nextDefenceTrain;
|
||||||
|
if (nextDefenceTrain === 999) {
|
||||||
|
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
||||||
|
const ignoresPenalty =
|
||||||
|
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
|
||||||
|
scenarioEffect === 'event_StrongAttacker' ||
|
||||||
|
scenarioEffect === 'event_MoreEffect';
|
||||||
|
const constValues = asRecord(world.getScenarioConfig().const);
|
||||||
|
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
|
||||||
|
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
|
||||||
|
const trainDelta = ignoresPenalty ? 0 : -3;
|
||||||
|
const atmosDelta = ignoresPenalty ? 0 : -6;
|
||||||
|
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
|
||||||
|
nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
world.updateGeneral(command.generalId, {
|
world.updateGeneral(command.generalId, {
|
||||||
meta: {
|
meta: nextMeta,
|
||||||
...general.meta,
|
train: nextTrain,
|
||||||
...command.settings,
|
atmos: nextAtmos,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
@@ -844,10 +907,8 @@ async function handleDropItem(
|
|||||||
if (!general) {
|
if (!general) {
|
||||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||||
}
|
}
|
||||||
const slot = (['horse', 'weapon', 'book', 'item'] as const).find(
|
const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType);
|
||||||
(candidate) => general.role.items[candidate] === command.itemType
|
if (!slot || !general.role.items[slot]) {
|
||||||
);
|
|
||||||
if (!slot) {
|
|
||||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
|
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
|
||||||
}
|
}
|
||||||
const nextGeneral = {
|
const nextGeneral = {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
|
||||||
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
|
|
||||||
|
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||||
|
id: 7,
|
||||||
|
userId: 'user-7',
|
||||||
|
name: '테스트장수',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
turnTime: new Date('0185-01-01T00:00:00Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
role: {
|
||||||
|
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: {
|
||||||
|
killturn: 12,
|
||||||
|
myset: 3,
|
||||||
|
defence_train: 80,
|
||||||
|
tnmt: 0,
|
||||||
|
use_treatment: 10,
|
||||||
|
use_auto_nation_turn: 1,
|
||||||
|
},
|
||||||
|
penalty: {},
|
||||||
|
officerLevel: 1,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 1_000,
|
||||||
|
crew: 100,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 90,
|
||||||
|
atmos: 90,
|
||||||
|
age: 20,
|
||||||
|
npcState: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildWorld = (
|
||||||
|
general = buildGeneral(),
|
||||||
|
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
|
||||||
|
) => {
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
|
||||||
|
meta: {
|
||||||
|
killturn: 24,
|
||||||
|
autorun_user: options.autorunLimit ? { limit_minutes: 60 } : {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: [general],
|
||||||
|
cities: [],
|
||||||
|
nations: [],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: { maxTrainByWar: 100, maxAtmosByWar: 100 },
|
||||||
|
environment: {
|
||||||
|
mapName: 'test',
|
||||||
|
unitSet: 'test',
|
||||||
|
...(options.scenarioEffect !== undefined ? { scenarioEffect: options.scenarioEffect } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenarioMeta: {
|
||||||
|
title: 'test',
|
||||||
|
startYear: 180,
|
||||||
|
life: null,
|
||||||
|
fiction: null,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||||
|
return { world, handler: createTurnDaemonCommandHandler({ world }) };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('my information world commands', () => {
|
||||||
|
it('normalizes legacy settings and charges myset only when defence mode changes', async () => {
|
||||||
|
const fixture = buildWorld();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fixture.handler.handle({
|
||||||
|
type: 'setMySetting',
|
||||||
|
generalId: 7,
|
||||||
|
settings: {
|
||||||
|
tnmt: 9,
|
||||||
|
defence_train: 94,
|
||||||
|
use_treatment: 200,
|
||||||
|
use_auto_nation_turn: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(fixture.world.getGeneralById(7)).toMatchObject({
|
||||||
|
train: 87,
|
||||||
|
atmos: 84,
|
||||||
|
meta: {
|
||||||
|
tnmt: 1,
|
||||||
|
defence_train: 999,
|
||||||
|
use_treatment: 100,
|
||||||
|
use_auto_nation_turn: 0,
|
||||||
|
myset: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fixture.handler.handle({
|
||||||
|
type: 'setMySetting',
|
||||||
|
generalId: 7,
|
||||||
|
settings: { tnmt: 0, defence_train: 999, use_treatment: 1 },
|
||||||
|
});
|
||||||
|
expect(fixture.world.getGeneralById(7)?.meta).toMatchObject({
|
||||||
|
tnmt: 0,
|
||||||
|
use_treatment: 10,
|
||||||
|
myset: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the event scenarios that waive the no-defence penalty', async () => {
|
||||||
|
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
|
||||||
|
await fixture.handler.handle({
|
||||||
|
type: 'setMySetting',
|
||||||
|
generalId: 7,
|
||||||
|
settings: { defence_train: 999 },
|
||||||
|
});
|
||||||
|
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
|
||||||
|
const allowed = buildWorld();
|
||||||
|
await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true });
|
||||||
|
expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72);
|
||||||
|
|
||||||
|
const blocked = buildWorld(buildGeneral(), { autorunLimit: true });
|
||||||
|
await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
|
||||||
|
});
|
||||||
|
expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops only the authenticated command target slot and rejects an empty slot', async () => {
|
||||||
|
const fixture = buildWorld();
|
||||||
|
await expect(
|
||||||
|
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' })
|
||||||
|
).resolves.toMatchObject({ ok: false });
|
||||||
|
await expect(
|
||||||
|
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' })
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import { AutorunNationPolicy } from '../src/turn/ai/policies.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
|
||||||
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
|
const general: TurnGeneral = {
|
||||||
|
id: 1,
|
||||||
|
userId: 'owner-1',
|
||||||
|
name: 'NPC군주',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 75, strength: 40, intelligence: 70 },
|
||||||
|
turnTime: new Date('0185-01-01T00:00:00Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 24 },
|
||||||
|
penalty: {},
|
||||||
|
officerLevel: 12,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 1_000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 1100,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: [general],
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '허창',
|
||||||
|
nationId: 1,
|
||||||
|
level: 7,
|
||||||
|
state: 0,
|
||||||
|
population: 100_000,
|
||||||
|
populationMax: 200_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '위',
|
||||||
|
color: '#777777',
|
||||||
|
capitalCityId: 1,
|
||||||
|
chiefGeneralId: 1,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 20_000,
|
||||||
|
power: 0,
|
||||||
|
level: 3,
|
||||||
|
typeCode: 'che_법가',
|
||||||
|
meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'basic' },
|
||||||
|
},
|
||||||
|
scenarioMeta: {
|
||||||
|
title: 'test',
|
||||||
|
startYear: 180,
|
||||||
|
life: null,
|
||||||
|
fiction: null,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
|
||||||
|
meta: { killturn: 24 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandEnv: TurnCommandEnv = {
|
||||||
|
baseGold: 1_000,
|
||||||
|
baseRice: 1_000,
|
||||||
|
develCost: 18,
|
||||||
|
maxResourceActionAmount: 10_000,
|
||||||
|
minAvailableRecruitPop: 30_000,
|
||||||
|
trainDelta: 5,
|
||||||
|
atmosDelta: 5,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
sabotageDefaultProb: 0.5,
|
||||||
|
sabotageProbCoefByStat: 0.01,
|
||||||
|
sabotageDefenceCoefByGeneralCount: 0.01,
|
||||||
|
sabotageDamageMin: 1,
|
||||||
|
sabotageDamageMax: 10,
|
||||||
|
defaultCrewTypeId: 1100,
|
||||||
|
maxGeneral: 100,
|
||||||
|
defaultNpcGold: 1_000,
|
||||||
|
defaultNpcRice: 1_000,
|
||||||
|
defaultSpecialDomestic: null,
|
||||||
|
defaultSpecialWar: null,
|
||||||
|
openingPartYear: 3,
|
||||||
|
initialNationGenLimit: 10,
|
||||||
|
maxTechLevel: 10,
|
||||||
|
techLevelIncYear: 5,
|
||||||
|
initialAllowedTechLevel: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitSet: UnitSetDefinition = {
|
||||||
|
id: 'basic',
|
||||||
|
name: 'basic',
|
||||||
|
defaultCrewTypeId: 1100,
|
||||||
|
armTypes: { 1: '보병' },
|
||||||
|
crewTypes: [
|
||||||
|
{
|
||||||
|
id: 1100,
|
||||||
|
armType: 1,
|
||||||
|
name: '보병',
|
||||||
|
attack: 100,
|
||||||
|
defence: 150,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 10,
|
||||||
|
magicCoef: 0,
|
||||||
|
cost: 9,
|
||||||
|
rice: 9,
|
||||||
|
requirements: [],
|
||||||
|
attackCoef: {},
|
||||||
|
defenceCoef: {},
|
||||||
|
info: [],
|
||||||
|
initSkillTrigger: null,
|
||||||
|
phaseSkillTrigger: null,
|
||||||
|
iActionList: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NPC policy lifecycle', () => {
|
||||||
|
it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => {
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||||
|
const handler = createTurnDaemonCommandHandler({ world });
|
||||||
|
const updates = {
|
||||||
|
npc_nation_policy: {
|
||||||
|
values: { reqNationGold: 4_321 },
|
||||||
|
priority: ['천도'],
|
||||||
|
valueSetter: '정책담당',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.handle({
|
||||||
|
type: 'setNationMeta',
|
||||||
|
nationId: 1,
|
||||||
|
updates,
|
||||||
|
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 });
|
||||||
|
|
||||||
|
const nation = world.getNationById(1)!;
|
||||||
|
expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy });
|
||||||
|
const policy = new AutorunNationPolicy({
|
||||||
|
general: world.getGeneralById(1)!,
|
||||||
|
aiOptions: null,
|
||||||
|
nationPolicy: asRecord(nation.meta).npc_nation_policy as Record<string, unknown>,
|
||||||
|
serverPolicy: null,
|
||||||
|
nation,
|
||||||
|
env: commandEnv,
|
||||||
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
|
unitSet,
|
||||||
|
});
|
||||||
|
expect(policy.reqNationGold).toBe(4_321);
|
||||||
|
expect(policy.priority).toEqual(['천도']);
|
||||||
|
expect(policy.reqNpcDevelGold).toBe(540);
|
||||||
|
expect(policy.reqNpcWarGold).toBe(3_900);
|
||||||
|
expect(policy.reqNpcWarRice).toBe(3_900);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.handle({
|
||||||
|
type: 'setNationMeta',
|
||||||
|
nationId: 1,
|
||||||
|
updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } },
|
||||||
|
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' });
|
||||||
|
expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({
|
||||||
|
reqNationGold: 4_321,
|
||||||
|
});
|
||||||
|
expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||||
|
const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR;
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const readImage = async (relative: string): Promise<Buffer> => {
|
||||||
|
for (const root of imageRoots) {
|
||||||
|
try {
|
||||||
|
return await readFile(resolve(root, relative));
|
||||||
|
} catch {
|
||||||
|
// Main checkout and feature worktrees have different image-root parents.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Reference image not found: ${relative}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulatorOptions = {
|
||||||
|
world: { startYear: 190, currentYear: 205, currentMonth: 8 },
|
||||||
|
config: {
|
||||||
|
maxTrainByWar: 120,
|
||||||
|
maxAtmosByWar: 120,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
},
|
||||||
|
unitSet: {
|
||||||
|
defaultCrewTypeId: 100,
|
||||||
|
crewTypes: [
|
||||||
|
{ id: 100, name: '보병', armType: 1 },
|
||||||
|
{ id: 200, name: '궁병', armType: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }],
|
||||||
|
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
|
||||||
|
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
|
||||||
|
items: { horse: [], weapon: [], book: [], item: [] },
|
||||||
|
nationLevels: [
|
||||||
|
{ level: 0, name: '방랑군' },
|
||||||
|
{ level: 1, name: '소국' },
|
||||||
|
],
|
||||||
|
cityLevels: [
|
||||||
|
{ level: 1, name: '소도시' },
|
||||||
|
{ level: 5, name: '대도시' },
|
||||||
|
],
|
||||||
|
dexLevels: [
|
||||||
|
{ level: 0, label: 'F', value: 0 },
|
||||||
|
{ level: 1, label: 'E', value: 1000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalMe = {
|
||||||
|
general: {
|
||||||
|
id: 7,
|
||||||
|
name: '유비',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: '22.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: 12,
|
||||||
|
stats: { leadership: 85, strength: 72, intelligence: 78 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 8765,
|
||||||
|
crew: 4321,
|
||||||
|
train: 99,
|
||||||
|
atmos: 98,
|
||||||
|
injury: 0,
|
||||||
|
experience: 900,
|
||||||
|
dedication: 100,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
city: { id: 1, level: 1, defence: 2222, wall: 3333 },
|
||||||
|
nation: { id: 1, level: 1, tech: 4500, typeCode: 'che_중립', capitalCityId: 1 },
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const importedGeneral = {
|
||||||
|
general: {
|
||||||
|
no: 7,
|
||||||
|
name: '유비',
|
||||||
|
officer_level: 12,
|
||||||
|
explevel: 30,
|
||||||
|
leadership: 85,
|
||||||
|
strength: 72,
|
||||||
|
intel: 78,
|
||||||
|
horse: null,
|
||||||
|
weapon: null,
|
||||||
|
book: null,
|
||||||
|
item: null,
|
||||||
|
injury: 0,
|
||||||
|
rice: 8765,
|
||||||
|
personal: 'che_대담',
|
||||||
|
special2: 'che_필살',
|
||||||
|
crew: 4321,
|
||||||
|
crewtype: 100,
|
||||||
|
atmos: 98,
|
||||||
|
train: 99,
|
||||||
|
dex1: 1000,
|
||||||
|
dex2: 0,
|
||||||
|
dex3: 0,
|
||||||
|
dex4: 0,
|
||||||
|
dex5: 0,
|
||||||
|
defence_train: 90,
|
||||||
|
warnum: 12,
|
||||||
|
killnum: 7,
|
||||||
|
killcrew: 3456,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulationResult = {
|
||||||
|
result: true,
|
||||||
|
reason: 'success',
|
||||||
|
datetime: '205-08',
|
||||||
|
avgWar: 5,
|
||||||
|
phase: 13,
|
||||||
|
killed: 1234,
|
||||||
|
maxKilled: 1400,
|
||||||
|
minKilled: 1100,
|
||||||
|
dead: 432,
|
||||||
|
maxDead: 500,
|
||||||
|
minDead: 400,
|
||||||
|
attackerRice: 321,
|
||||||
|
defenderRice: 654,
|
||||||
|
attackerSkills: { 필살: 2 },
|
||||||
|
defendersSkills: [{ 회피: 1 }],
|
||||||
|
lastWarLog: {
|
||||||
|
generalHistoryLog: '',
|
||||||
|
generalActionLog: '',
|
||||||
|
generalBattleResultLog: '<span>유비가 모의전에서 승리했습니다.</span>',
|
||||||
|
generalBattleDetailLog: '<span>필살 발동, 피해 1,234</span>',
|
||||||
|
nationalHistoryLog: '',
|
||||||
|
globalHistoryLog: '',
|
||||||
|
globalActionLog: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fixture = {
|
||||||
|
hasGeneral: boolean;
|
||||||
|
failNextSimulation?: boolean;
|
||||||
|
queueFirst?: boolean;
|
||||||
|
pollingCount: number;
|
||||||
|
requests: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const installImages = async (page: Page) => {
|
||||||
|
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
|
||||||
|
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
body: await readImage(`game/${filename}`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installApi = async (page: Page, fixture: Fixture) => {
|
||||||
|
await installImages(page);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright');
|
||||||
|
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const operations = operationNames(route);
|
||||||
|
const results = operations.map((operation) => {
|
||||||
|
fixture.requests.push(operation);
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({
|
||||||
|
year: 205,
|
||||||
|
month: 8,
|
||||||
|
myGeneral: fixture.hasGeneral ? { name: '유비', picture: '22.jpg' } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'battle.getSimulatorContext') return response(simulatorOptions);
|
||||||
|
if (operation === 'general.me') return response(fixture.hasGeneral ? generalMe : null);
|
||||||
|
if (operation === 'battle.getGeneralList') {
|
||||||
|
return response({
|
||||||
|
myNationId: 1,
|
||||||
|
myGeneralId: 7,
|
||||||
|
nations: [{ id: 1, name: '촉', color: '#8fbc8f' }],
|
||||||
|
generalsByNation: { 1: [{ id: 7, name: '유비', npcState: 0 }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'battle.getGeneralDetail') return response(importedGeneral);
|
||||||
|
if (operation === 'battle.simulate') {
|
||||||
|
if (fixture.failNextSimulation) {
|
||||||
|
fixture.failNextSimulation = false;
|
||||||
|
return errorResponse(operation, '시뮬레이터 입력 오류');
|
||||||
|
}
|
||||||
|
if (fixture.queueFirst) {
|
||||||
|
return response({ status: 'queued', jobId: 'job-playwright' });
|
||||||
|
}
|
||||||
|
return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult });
|
||||||
|
}
|
||||||
|
if (operation === 'battle.getSimulation') {
|
||||||
|
fixture.pollingCount += 1;
|
||||||
|
if (fixture.pollingCount === 1) {
|
||||||
|
return response({ status: 'queued', jobId: 'job-playwright' });
|
||||||
|
}
|
||||||
|
return response({
|
||||||
|
status: 'completed',
|
||||||
|
jobId: 'job-playwright',
|
||||||
|
payload: simulationResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const gotoSimulator = async (page: Page) => {
|
||||||
|
await page.goto('battle-simulator');
|
||||||
|
await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible();
|
||||||
|
await expect(page.getByText('출병자 설정')).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => {
|
||||||
|
const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] };
|
||||||
|
await installApi(page, fixture);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await gotoSimulator(page);
|
||||||
|
|
||||||
|
const notice = page.getByLabel('시뮬레이터 데이터 안내');
|
||||||
|
const noticeRect = await notice.boundingBox();
|
||||||
|
expect(noticeRect?.width).toBeGreaterThan(900);
|
||||||
|
expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '독립 기본값' }).click();
|
||||||
|
await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190');
|
||||||
|
await expect(page.getByLabel('월')).toHaveValue('1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '현재 게임 환경 적용' }).click();
|
||||||
|
await expect(page.getByLabel('연도', { exact: true })).toHaveValue('205');
|
||||||
|
await expect(page.getByLabel('월')).toHaveValue('8');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '내 장수를 출병자로' }).click();
|
||||||
|
await expect(page.getByLabel('이름').first()).toHaveValue('유비');
|
||||||
|
await expect(page.getByLabel('병사').first()).toHaveValue('4321');
|
||||||
|
|
||||||
|
const battleButton = page.getByRole('button', { name: '전투', exact: true });
|
||||||
|
await battleButton.hover();
|
||||||
|
expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||||
|
await page.getByLabel('시드').fill('playwright-fixed-seed');
|
||||||
|
await battleButton.click();
|
||||||
|
|
||||||
|
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('5', { exact: true })).toBeVisible();
|
||||||
|
expect(fixture.pollingCount).toBe(2);
|
||||||
|
expect(fixture.requests).toContain('battle.getSimulation');
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'battle-simulator-core-desktop.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps simulation available without a game general and preserves input after an API error', async ({ page }) => {
|
||||||
|
const fixture: Fixture = {
|
||||||
|
hasGeneral: false,
|
||||||
|
failNextSimulation: true,
|
||||||
|
pollingCount: 0,
|
||||||
|
requests: [],
|
||||||
|
};
|
||||||
|
await installApi(page, fixture);
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await gotoSimulator(page);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/battle-simulator/);
|
||||||
|
await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled();
|
||||||
|
await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel('시드').fill('keep-this-seed');
|
||||||
|
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||||
|
await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||||
|
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
|
||||||
|
|
||||||
|
const notice = page.getByLabel('시뮬레이터 데이터 안내');
|
||||||
|
expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column');
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'battle-simulator-core-mobile.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const refBaseUrl = process.env.REF_BATTLE_SIM_URL;
|
||||||
|
const refPasswordFile = process.env.REF_USER_PASSWORD_FILE;
|
||||||
|
const refUsername = process.env.REF_USER_ID ?? 'refuser1';
|
||||||
|
const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR;
|
||||||
|
const refTest = refBaseUrl && refPasswordFile ? test : test.skip;
|
||||||
|
|
||||||
|
refTest('runs the legacy simulator in the same Chromium and captures its rendered contract', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
if (!refBaseUrl || !refPasswordFile) {
|
||||||
|
throw new Error('REF_BATTLE_SIM_URL and REF_USER_PASSWORD_FILE are required');
|
||||||
|
}
|
||||||
|
const password = (await readFile(refPasswordFile, 'utf8')).trim();
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(refBaseUrl, { waitUntil: 'networkidle' });
|
||||||
|
await page.locator('#username').fill(refUsername);
|
||||||
|
await page.locator('#password').fill(password);
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const loginResponse = await page
|
||||||
|
.context()
|
||||||
|
.request.post(new URL('api.php?path=Login/LoginByID', refBaseUrl).toString(), {
|
||||||
|
data: { username: refUsername, password: passwordHash },
|
||||||
|
});
|
||||||
|
expect(loginResponse.status()).toBe(200);
|
||||||
|
await expect(loginResponse.json()).resolves.toMatchObject({ result: true });
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/battle_simulator.php', refBaseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
});
|
||||||
|
const battleButton = page.locator('.btn-begin_battle');
|
||||||
|
await expect(battleButton).toBeVisible();
|
||||||
|
const container = page.locator('#container');
|
||||||
|
const rect = await container.boundingBox();
|
||||||
|
expect(rect?.width).toBeGreaterThanOrEqual(995);
|
||||||
|
expect(rect?.width).toBeLessThanOrEqual(1005);
|
||||||
|
|
||||||
|
// A login with no game general leaves the legacy nation selects without a
|
||||||
|
// selected option. Choose the first legal independent value before running.
|
||||||
|
await page.locator('.form_nation_type').evaluateAll((elements) => {
|
||||||
|
for (const element of elements) {
|
||||||
|
const select = element as HTMLSelectElement;
|
||||||
|
select.selectedIndex = 0;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await expect(page.locator('.form_nation_type').first()).not.toHaveValue('');
|
||||||
|
|
||||||
|
await battleButton.hover();
|
||||||
|
expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||||
|
const simulationResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/j_simulate_battle.php') && response.status() === 200,
|
||||||
|
{ timeout: 90_000 }
|
||||||
|
);
|
||||||
|
await battleButton.click();
|
||||||
|
await simulationResponse;
|
||||||
|
await expect(page.locator('#generalBattleResultLog')).not.toBeEmpty();
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'battle-simulator-ref-desktop.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,28 @@
|
|||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR;
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||||
|
const readImage = async (relativePath: string): Promise<Buffer> => {
|
||||||
|
if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`);
|
||||||
|
for (const root of imageRoots) {
|
||||||
|
try {
|
||||||
|
return await readFile(resolve(root, relativePath));
|
||||||
|
} catch {
|
||||||
|
// Product checkout and feature worktrees have different image-root parents.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Fixture image not found: ${relativePath}`);
|
||||||
|
};
|
||||||
|
const imageContentType = (relativePath: string) => {
|
||||||
|
if (relativePath.endsWith('.png')) return 'image/png';
|
||||||
|
if (relativePath.endsWith('.gif')) return 'image/gif';
|
||||||
|
return 'image/jpeg';
|
||||||
|
};
|
||||||
const operationNames = (route: Route) =>
|
const operationNames = (route: Route) =>
|
||||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||||
const city = {
|
const city = {
|
||||||
@@ -46,19 +68,68 @@ const layout = {
|
|||||||
regionMap: { 1: '하북' },
|
regionMap: { 1: '하북' },
|
||||||
levelMap: { 8: '특' },
|
levelMap: { 8: '특' },
|
||||||
};
|
};
|
||||||
|
const generalContext = {
|
||||||
|
general: {
|
||||||
|
id: 1,
|
||||||
|
name: '장수',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
officerLevel: 1,
|
||||||
|
npcState: 0,
|
||||||
|
troopId: 0,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 500,
|
||||||
|
train: 90,
|
||||||
|
atmos: 90,
|
||||||
|
injury: 0,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||||
|
},
|
||||||
|
city,
|
||||||
|
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
};
|
||||||
|
const emptyMessages = {
|
||||||
|
private: [],
|
||||||
|
national: [],
|
||||||
|
public: [],
|
||||||
|
diplomacy: [],
|
||||||
|
sequence: -1,
|
||||||
|
hasMore: { private: false, national: false, public: false, diplomacy: false },
|
||||||
|
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
|
||||||
|
canRespondDiplomacy: false,
|
||||||
|
};
|
||||||
|
|
||||||
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
|
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem('sammo-game-token', 'ga_info');
|
localStorage.setItem('sammo-game-token', 'ga_info');
|
||||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
});
|
});
|
||||||
await page.route('**/image/game/**', (route) =>
|
await page.route('**/image/**', async (route) => {
|
||||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? '');
|
||||||
);
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: imageContentType(relativePath),
|
||||||
|
body: await readImage(relativePath),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route('**/che/api/trpc/**', async (route) => {
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
const results = operationNames(route).map((operation) => {
|
const results = operationNames(route).map((operation) => {
|
||||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
||||||
if (operation === 'join.getConfig') return response({});
|
if (operation === 'join.getConfig') return response({});
|
||||||
|
if (operation === 'general.me') return response(generalContext);
|
||||||
|
if (operation === 'world.getMap') return response(map);
|
||||||
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral') return response([]);
|
||||||
|
if (operation === 'messages.getRecent') return response(emptyMessages);
|
||||||
|
if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
if (operation === 'nation.getNationInfo')
|
if (operation === 'nation.getNationInfo')
|
||||||
return response({
|
return response({
|
||||||
nation: {
|
nation: {
|
||||||
@@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: '업',
|
name: '업',
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
|
nationColor: '#008000',
|
||||||
level: 8,
|
level: 8,
|
||||||
region: 1,
|
region: 1,
|
||||||
population: mode === 'wanderer' ? null : 150000,
|
population: mode === 'wanderer' ? null : 150000,
|
||||||
@@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
|||||||
intelligence: 50,
|
intelligence: 50,
|
||||||
injury: 0,
|
injury: 0,
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
leadershipBonus: 0,
|
||||||
defenceTrain: 80,
|
defenceTrain: 80,
|
||||||
crewTypeId: 1,
|
crewTypeId: 1,
|
||||||
|
crewTypeName: '보병',
|
||||||
crew: 500,
|
crew: 500,
|
||||||
train: 90,
|
train: 90,
|
||||||
atmos: 90,
|
atmos: 90,
|
||||||
turns: ['징병'],
|
turns: ['징병'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
forceSummary: {
|
||||||
|
enemyCrew: 0,
|
||||||
|
enemyArmedGenerals: 0,
|
||||||
|
enemyGenerals: 0,
|
||||||
|
ownCrew: mode === 'wanderer' ? 0 : 500,
|
||||||
|
ownArmedGenerals: mode === 'wanderer' ? 0 : 1,
|
||||||
|
ownGenerals: mode === 'wanderer' ? 0 : 1,
|
||||||
|
ready90Crew: mode === 'wanderer' ? 0 : 500,
|
||||||
|
ready90Generals: mode === 'wanderer' ? 0 : 1,
|
||||||
|
ready60Crew: mode === 'wanderer' ? 0 : 500,
|
||||||
|
ready60Generals: mode === 'wanderer' ? 0 : 1,
|
||||||
|
defenceReadyCrew: mode === 'wanderer' ? 0 : 500,
|
||||||
|
defenceReadyGenerals: mode === 'wanderer' ? 0 : 1,
|
||||||
|
},
|
||||||
lastExecute: '2026-07-26',
|
lastExecute: '2026-07-26',
|
||||||
});
|
});
|
||||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||||
@@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => {
|
|||||||
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
|
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
for (const [path, selector] of [
|
for (const [path, selector, fontSize, fontFamily, borderCollapse] of [
|
||||||
['nation/info', '.legacy-info-page'],
|
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
|
||||||
['nation/cities', '.nation-cities-page'],
|
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
|
||||||
['global-info', '.global-page'],
|
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
|
||||||
['current-city', '.city-page'],
|
['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
|
||||||
] as const) {
|
] as const) {
|
||||||
await go(page, path);
|
await go(page, path);
|
||||||
await expect(page.locator(selector)).toBeVisible();
|
await expect(page.locator(selector)).toBeVisible();
|
||||||
@@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
|
|||||||
});
|
});
|
||||||
expect(box.width).toBe(1000);
|
expect(box.width).toBe(1000);
|
||||||
expect(box.x).toBe(100);
|
expect(box.x).toBe(100);
|
||||||
expect(box.fontSize).toBe('14px');
|
expect(box.fontSize).toBe(fontSize);
|
||||||
expect(box.fontFamily).toContain('Pretendard');
|
expect(box.fontFamily).toContain(fontFamily);
|
||||||
expect(
|
expect(
|
||||||
await page
|
await page
|
||||||
.locator('table')
|
.locator('table')
|
||||||
.first()
|
.first()
|
||||||
.evaluate((el) => getComputedStyle(el).borderCollapse)
|
.evaluate((el) => getComputedStyle(el).borderCollapse)
|
||||||
).toBe('collapse');
|
).toBe(borderCollapse);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({
|
|||||||
|
|
||||||
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
|
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
|
||||||
await install(page, 'admin');
|
await install(page, 'admin');
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await go(page, 'current-city');
|
await go(page, 'current-city');
|
||||||
await expect(page.locator('.generals')).toContainText('장수');
|
await expect(page.locator('.generals')).toContainText('장수');
|
||||||
await expect(page.locator('.generals')).toContainText('90');
|
await expect(page.locator('.generals')).toContainText('90');
|
||||||
|
const legacyGeometry = await page.evaluate(() => {
|
||||||
|
const rect = (selector: string) => {
|
||||||
|
const box = document.querySelector(selector)?.getBoundingClientRect();
|
||||||
|
return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null;
|
||||||
|
};
|
||||||
|
const icon = document.querySelector<HTMLImageElement>('.general-icon');
|
||||||
|
return {
|
||||||
|
selector: rect('#citySelector'),
|
||||||
|
stats: rect('.stats'),
|
||||||
|
generals: rect('.generals'),
|
||||||
|
titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign,
|
||||||
|
icon: icon
|
||||||
|
? {
|
||||||
|
...rect('.general-icon'),
|
||||||
|
naturalWidth: icon.naturalWidth,
|
||||||
|
naturalHeight: icon.naturalHeight,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
|
||||||
|
expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 });
|
||||||
|
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 });
|
||||||
|
expect(legacyGeometry.titleAlign).toBe('start');
|
||||||
|
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
const computedDom = await page.evaluate(() => {
|
||||||
|
const measure = (selector: string) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) return null;
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||||
|
style: {
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderCollapse: style.borderCollapse,
|
||||||
|
padding: style.padding,
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const icon = document.querySelector<HTMLImageElement>('.general-icon');
|
||||||
|
return {
|
||||||
|
body: measure('body'),
|
||||||
|
page: measure('.city-page'),
|
||||||
|
selector: measure('#citySelector'),
|
||||||
|
stats: measure('.stats'),
|
||||||
|
generals: measure('.generals'),
|
||||||
|
title: measure('.city-title'),
|
||||||
|
firstIcon: icon
|
||||||
|
? {
|
||||||
|
...measure('.general-icon'),
|
||||||
|
naturalWidth: icon.naturalWidth,
|
||||||
|
naturalHeight: icon.naturalHeight,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
document: {
|
||||||
|
width: document.documentElement.scrollWidth,
|
||||||
|
height: document.documentElement.scrollHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
resolve(artifactRoot, 'core-current-city-computed-dom.json'),
|
||||||
|
`${JSON.stringify(computedDom, null, 2)}\n`
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'core-current-city-desktop.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await go(page, '');
|
||||||
|
const cityLink = page.locator('.map-city').first();
|
||||||
|
await expect(cityLink).toBeVisible();
|
||||||
|
await cityLink.hover();
|
||||||
|
await expect(cityLink).toHaveCSS('cursor', 'pointer');
|
||||||
|
await cityLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/);
|
||||||
|
await expect(page.locator('.stats')).toContainText('업');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, resolve } from 'node:path';
|
||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
|
||||||
|
const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT;
|
||||||
|
const operationNames = (route: Route) =>
|
||||||
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||||
|
|
||||||
|
const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => {
|
||||||
|
if (!parityArtifactDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(parityArtifactDir, { recursive: true });
|
||||||
|
await Promise.all([
|
||||||
|
page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }),
|
||||||
|
writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FixtureState = {
|
||||||
|
permission: 'head' | 'member';
|
||||||
|
myset: number;
|
||||||
|
settingMutations: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const myGeneral = (state: FixtureState) => ({
|
||||||
|
general: {
|
||||||
|
id: 7,
|
||||||
|
name: '검증장수',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: state.permission === 'head' ? 5 : 1,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 2_000,
|
||||||
|
crew: 300,
|
||||||
|
train: 80,
|
||||||
|
atmos: 90,
|
||||||
|
injury: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 200,
|
||||||
|
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
city: { id: 1, name: '업', level: 8, nationId: 1 },
|
||||||
|
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
||||||
|
settings: {
|
||||||
|
tnmt: 0,
|
||||||
|
defence_train: 80,
|
||||||
|
use_treatment: 21,
|
||||||
|
use_auto_nation_turn: 1,
|
||||||
|
myset: state.myset,
|
||||||
|
},
|
||||||
|
penalties: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const battleCenter = (state: FixtureState) => ({
|
||||||
|
me: {
|
||||||
|
id: 7,
|
||||||
|
officerLevel: state.permission === 'head' ? 5 : 1,
|
||||||
|
permissionLevel: state.permission === 'head' ? 2 : 0,
|
||||||
|
},
|
||||||
|
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 1,
|
||||||
|
turnTermMinutes: 10,
|
||||||
|
generals: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '검증장수',
|
||||||
|
npcState: 0,
|
||||||
|
officerLevel: state.permission === 'head' ? 5 : 1,
|
||||||
|
cityId: 1,
|
||||||
|
turnTime: '2026-01-01 00:10:00',
|
||||||
|
recentWar: '2026-01-01 00:00:00',
|
||||||
|
warnum: 3,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
experience: 100,
|
||||||
|
dedication: 200,
|
||||||
|
injury: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 2_000,
|
||||||
|
crew: 300,
|
||||||
|
train: 80,
|
||||||
|
atmos: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: '다른장수',
|
||||||
|
npcState: 2,
|
||||||
|
officerLevel: 1,
|
||||||
|
cityId: 1,
|
||||||
|
turnTime: '2026-01-01 00:20:00',
|
||||||
|
recentWar: null,
|
||||||
|
warnum: 0,
|
||||||
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 500,
|
||||||
|
rice: 500,
|
||||||
|
crew: 100,
|
||||||
|
train: 60,
|
||||||
|
atmos: 60,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const install = async (page: Page, state: FixtureState) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('sammo-game-token', 'menu-token');
|
||||||
|
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
});
|
||||||
|
await page.route('**/image/game/**', async (route) => {
|
||||||
|
const filename = basename(new URL(route.request().url()).pathname);
|
||||||
|
if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
body: await readFile(resolve(legacyImageRoot, filename)),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const operations = operationNames(route);
|
||||||
|
const results = operations.map((operation) => {
|
||||||
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
||||||
|
if (operation === 'join.getConfig') return response({});
|
||||||
|
if (operation === 'general.me') return response(myGeneral(state));
|
||||||
|
if (operation === 'world.getState')
|
||||||
|
return response({
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: { npcMode: 0, const: { availableInstantAction: {} } },
|
||||||
|
meta: {
|
||||||
|
turntime: '2026-01-01T00:00:00.000Z',
|
||||||
|
opentime: '2025-12-01T00:00:00.000Z',
|
||||||
|
autorun_user: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (operation === 'public.getTraffic')
|
||||||
|
return response({
|
||||||
|
history: [
|
||||||
|
{ year: 185, month: 1, date: '2026-01-01T00:00:00.000Z', refresh: 120, online: 8 },
|
||||||
|
{ year: 185, month: 2, date: '2026-01-01T00:10:00.000Z', refresh: 240, online: 12 },
|
||||||
|
],
|
||||||
|
maxRefresh: 240,
|
||||||
|
maxOnline: 12,
|
||||||
|
suspects: [
|
||||||
|
{ generalId: null, name: '합계', refresh: 360, refreshScoreTotal: 36 },
|
||||||
|
{ generalId: 7, name: '검증장수', refresh: 240, refreshScoreTotal: 24 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (operation === 'general.getMyLog')
|
||||||
|
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
|
||||||
|
if (operation === 'general.setMySetting') {
|
||||||
|
const raw = route.request().postDataJSON() as { input?: { json?: Record<string, unknown> } };
|
||||||
|
state.settingMutations.push(raw.input?.json ?? {});
|
||||||
|
state.myset = Math.max(0, state.myset - 1);
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
|
if (operation === 'nation.getBattleCenter') {
|
||||||
|
if (state.permission === 'member') {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: '권한이 부족합니다.',
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return response(battleCenter(state));
|
||||||
|
}
|
||||||
|
if (operation === 'nation.getGeneralLog') {
|
||||||
|
const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction')
|
||||||
|
? 'generalAction'
|
||||||
|
: operation;
|
||||||
|
return response({ type, generalId: 7, logs: [{ id: 1, text: '<Y>감찰 기록</>' }] });
|
||||||
|
}
|
||||||
|
return response({ ok: true });
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
||||||
|
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] };
|
||||||
|
await install(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('traffic');
|
||||||
|
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
|
||||||
|
|
||||||
|
const geometry = await page.locator('#traffic-container').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const title = element.querySelector<HTMLElement>('.title-table')!.getBoundingClientRect();
|
||||||
|
const charts = [...element.querySelectorAll<HTMLElement>('.chart-table')].map((chart) =>
|
||||||
|
chart.getBoundingClientRect()
|
||||||
|
);
|
||||||
|
const row = element.querySelector<HTMLElement>('.chart-row')!.getBoundingClientRect();
|
||||||
|
const bar = element.querySelector<HTMLElement>('.big-bar')!.getBoundingClientRect();
|
||||||
|
const suspect = element.querySelector<HTMLElement>('.suspect-table')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
minWidth: getComputedStyle(element).minWidth,
|
||||||
|
fontSize: getComputedStyle(element).fontSize,
|
||||||
|
fontFamily: getComputedStyle(element).fontFamily,
|
||||||
|
titleWidth: title.width,
|
||||||
|
chartWidths: charts.map((chart) => chart.width),
|
||||||
|
chartGap: charts[1]!.x - charts[0]!.right,
|
||||||
|
rowHeight: row.height,
|
||||||
|
barHeight: bar.height,
|
||||||
|
suspectWidth: suspect.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBe(1016);
|
||||||
|
expect(geometry.minWidth).toBe('1016px');
|
||||||
|
expect(geometry.fontSize).toBe('14px');
|
||||||
|
expect(geometry.fontFamily).toContain('Pretendard');
|
||||||
|
expect(geometry.titleWidth).toBe(1000);
|
||||||
|
expect(geometry.chartWidths).toEqual([483, 483]);
|
||||||
|
expect(geometry.chartGap).toBe(26);
|
||||||
|
expect(geometry.rowHeight).toBe(31);
|
||||||
|
expect(geometry.barHeight).toBe(30);
|
||||||
|
expect(geometry.suspectWidth).toBeGreaterThanOrEqual(994);
|
||||||
|
await persistParityArtifact(page, 'traffic-desktop', geometry);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
const mobileWidth = await page
|
||||||
|
.locator('#traffic-container')
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().width);
|
||||||
|
expect(mobileWidth).toBe(1016);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
||||||
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
||||||
|
await install(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('my-page');
|
||||||
|
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||||
|
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||||
|
|
||||||
|
const desktop = await page.locator('#container').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const title = element.querySelector<HTMLElement>('.title-row')!.getBoundingClientRect();
|
||||||
|
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||||||
|
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
|
||||||
|
const save = saveButton.getBoundingClientRect();
|
||||||
|
const customCss = element.querySelector<HTMLElement>('#custom_css')!.getBoundingClientRect();
|
||||||
|
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
minWidth: getComputedStyle(element).minWidth,
|
||||||
|
fontSize: getComputedStyle(element).fontSize,
|
||||||
|
columns,
|
||||||
|
titleHeight: title.height,
|
||||||
|
settingsOffset: settings.x - rect.x,
|
||||||
|
saveWidth: save.width,
|
||||||
|
saveHeight: save.height,
|
||||||
|
saveBackground: getComputedStyle(saveButton).backgroundColor,
|
||||||
|
customCssWidth: customCss.width,
|
||||||
|
customCssHeight: customCss.height,
|
||||||
|
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||||
|
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(desktop.width).toBe(1000);
|
||||||
|
expect(desktop.minWidth).toBe('500px');
|
||||||
|
expect(desktop.fontSize).toBe('14px');
|
||||||
|
expect(desktop.columns.split(' ')).toHaveLength(2);
|
||||||
|
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
||||||
|
expect(desktop.settingsOffset).toBeCloseTo(500, 0);
|
||||||
|
expect(desktop.saveWidth).toBe(160);
|
||||||
|
expect(desktop.saveHeight).toBe(30);
|
||||||
|
expect(desktop.saveBackground).toBe('rgb(34, 85, 0)');
|
||||||
|
expect(desktop.customCssWidth).toBe(420);
|
||||||
|
expect(desktop.customCssHeight).toBe(150);
|
||||||
|
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||||
|
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
||||||
|
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('select')
|
||||||
|
.filter({ has: page.locator('option[value="999"]') })
|
||||||
|
.selectOption('999');
|
||||||
|
await page.locator('#set_my_setting').click();
|
||||||
|
await expect.poll(() => state.settingMutations.length).toBe(1);
|
||||||
|
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.reload();
|
||||||
|
const mobile = await page.locator('#container').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
||||||
|
settingsOffset: settings.x - rect.x,
|
||||||
|
settingsWidth: settings.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobile).toMatchObject({
|
||||||
|
width: 500,
|
||||||
|
scrollWidth: 500,
|
||||||
|
columns: '500px',
|
||||||
|
settingsOffset: 0,
|
||||||
|
settingsWidth: 500,
|
||||||
|
});
|
||||||
|
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
||||||
|
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
||||||
|
await install(page, head);
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('battle-center');
|
||||||
|
await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible();
|
||||||
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
||||||
|
await page.getByRole('button', { name: '다음 ▶' }).click();
|
||||||
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
||||||
|
const geometry = await page.locator('.battle-page').evaluate((element) => {
|
||||||
|
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
||||||
|
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
|
||||||
|
const logBlock = element.querySelector<HTMLElement>('.log-block')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: element.getBoundingClientRect().width,
|
||||||
|
fontSize: getComputedStyle(element).fontSize,
|
||||||
|
selectorColumns: getComputedStyle(selector).gridTemplateColumns,
|
||||||
|
selectorHeight: selector.getBoundingClientRect().height,
|
||||||
|
controlWidths: controls.map((control) => control.width),
|
||||||
|
logBlockWidth: logBlock.width,
|
||||||
|
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||||
|
generalBackgroundImage: getComputedStyle(element.querySelector<HTMLElement>('.battle-general-card')!)
|
||||||
|
.backgroundImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBe(1000);
|
||||||
|
expect(geometry.fontSize).toBe('14px');
|
||||||
|
expect(geometry.selectorColumns.split(' ')).toHaveLength(4);
|
||||||
|
expect(geometry.selectorHeight).toBeCloseTo(36, 0);
|
||||||
|
expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
||||||
|
expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0);
|
||||||
|
expect(geometry.logBlockWidth).toBeCloseTo(500, 0);
|
||||||
|
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
||||||
|
expect(geometry.generalBackgroundImage).toContain('back_blue.jpg');
|
||||||
|
await persistParityArtifact(page, 'core-battle-center-desktop', geometry);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({
|
||||||
|
columns: getComputedStyle(element).gridTemplateColumns,
|
||||||
|
controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
|
||||||
|
}));
|
||||||
|
expect(mobileGeometry.columns.split(' ')).toHaveLength(4);
|
||||||
|
expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
||||||
|
expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0);
|
||||||
|
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
||||||
|
|
||||||
|
await page.unrouteAll({ behavior: 'wait' });
|
||||||
|
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] };
|
||||||
|
await install(page, member);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const operations = (route: Route) =>
|
||||||
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||||
|
const general = {
|
||||||
|
id: 1,
|
||||||
|
name: '테스트장수',
|
||||||
|
npcState: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
cityId: 1,
|
||||||
|
cityName: null,
|
||||||
|
troopId: 0,
|
||||||
|
troopName: null,
|
||||||
|
officerCity: 0,
|
||||||
|
officerCityName: null,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
experienceLevel: 9,
|
||||||
|
dedicationLevel: 1,
|
||||||
|
injury: 0,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 2000,
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
belong: 1,
|
||||||
|
refreshScoreTotal: 10,
|
||||||
|
permission: 'normal',
|
||||||
|
};
|
||||||
|
const install = async (page: Page, secretAllowed = true) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('sammo-game-token', 'ga_general');
|
||||||
|
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const results = operations(route).map((operation) => {
|
||||||
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '테스트장수' } });
|
||||||
|
if (operation === 'join.getConfig') return response({});
|
||||||
|
if (operation === 'nation.getGeneralList')
|
||||||
|
return response({
|
||||||
|
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
||||||
|
viewer: { generalId: 1, permission: 0 },
|
||||||
|
generals: [general],
|
||||||
|
});
|
||||||
|
if (operation === 'nation.getSecretGeneralList') {
|
||||||
|
if (!secretAllowed)
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: '권한이 부족합니다.',
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return response({
|
||||||
|
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
||||||
|
viewer: { generalId: 1, permission: 1 },
|
||||||
|
summary: {
|
||||||
|
gold: 1000,
|
||||||
|
rice: 2000,
|
||||||
|
crew: 300,
|
||||||
|
generalCount: 1,
|
||||||
|
averageGold: 1000,
|
||||||
|
averageRice: 2000,
|
||||||
|
readiness: {
|
||||||
|
90: { crew: 300, generals: 1 },
|
||||||
|
80: { crew: 300, generals: 1 },
|
||||||
|
60: { crew: 300, generals: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generals: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '테스트장수',
|
||||||
|
npcState: 0,
|
||||||
|
injury: 0,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
leadershipBonus: 0,
|
||||||
|
experienceLevel: 9,
|
||||||
|
troopId: 0,
|
||||||
|
troopName: null,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 2000,
|
||||||
|
cityId: 1,
|
||||||
|
cityName: '업',
|
||||||
|
defenceTrain: 90,
|
||||||
|
defenceTrainText: '☆',
|
||||||
|
crewTypeId: 1,
|
||||||
|
crew: 300,
|
||||||
|
train: 90,
|
||||||
|
atmos: 90,
|
||||||
|
killTurn: 7,
|
||||||
|
turnTime: '2026-01-01T01:02:00.000Z',
|
||||||
|
reservedCommands: ['징병', '훈련'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||||
|
});
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('nation generals keeps the 1000px legacy grid and redacted member columns', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('nation/generals');
|
||||||
|
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
|
||||||
|
await expect(page.locator('#nation-general-list')).toContainText('?');
|
||||||
|
const computed = await page.locator('.general-page').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
|
||||||
|
});
|
||||||
|
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
|
||||||
|
expect(computed.fontFamily).toContain('Times New Roman');
|
||||||
|
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
|
||||||
|
'separate'
|
||||||
|
);
|
||||||
|
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
|
||||||
|
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
for (const path of ['nation/generals', 'nation/secret']) {
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(
|
||||||
|
page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list')
|
||||||
|
).toBeVisible();
|
||||||
|
expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('nation/secret');
|
||||||
|
await expect(page.locator('.summary')).toContainText('전체 금');
|
||||||
|
await expect(page.locator('#secret-general-list')).toContainText('1 : 징병');
|
||||||
|
expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
|
||||||
|
|
||||||
|
await page.unroute('**/che/api/trpc/**');
|
||||||
|
await install(page, false);
|
||||||
|
await page.goto('nation/secret');
|
||||||
|
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||||||
|
await expect(page.locator('#secret-general-list')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -8,7 +8,17 @@ const baseURL = `http://127.0.0.1:${port}/che/`;
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'],
|
testMatch: [
|
||||||
|
'troop.spec.ts',
|
||||||
|
'board.spec.ts',
|
||||||
|
'inGameInfo.spec.ts',
|
||||||
|
'inGameMenus.spec.ts',
|
||||||
|
'nationOffices.spec.ts',
|
||||||
|
'nationGeneralSecret.spec.ts',
|
||||||
|
'npcPolicy.spec.ts',
|
||||||
|
'battleSimulator.spec.ts',
|
||||||
|
'battleSimulatorRef.spec.ts',
|
||||||
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
||||||
|
"test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
|
||||||
|
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "node -e \"console.log('test not configured')\"",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Props {
|
|||||||
options: BattleSimOptions;
|
options: BattleSimOptions;
|
||||||
mode: 'attacker' | 'defender';
|
mode: 'attacker' | 'defender';
|
||||||
title: string;
|
title: string;
|
||||||
|
canImportServer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -59,7 +60,19 @@ const officerLevelOptions = [
|
|||||||
<div class="general-subtitle">No {{ general.no }}</div>
|
<div class="general-subtitle">No {{ general.no }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="general-actions">
|
<div class="general-actions">
|
||||||
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
|
<button
|
||||||
|
class="action"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canImportServer"
|
||||||
|
:title="
|
||||||
|
canImportServer
|
||||||
|
? '게임 서버의 장수 정보를 불러옵니다.'
|
||||||
|
: '게임 장수를 보유해야 사용할 수 있습니다.'
|
||||||
|
"
|
||||||
|
@click="emit('import')"
|
||||||
|
>
|
||||||
|
서버에서 가져오기
|
||||||
|
</button>
|
||||||
<button class="action" type="button" @click="emit('save')">저장</button>
|
<button class="action" type="button" @click="emit('save')">저장</button>
|
||||||
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
||||||
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
||||||
@@ -187,21 +200,11 @@ const officerLevelOptions = [
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>훈련</span>
|
<span>훈련</span>
|
||||||
<input
|
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
|
||||||
v-model.number="general.train"
|
|
||||||
type="number"
|
|
||||||
min="40"
|
|
||||||
:max="options.config.maxTrainByWar"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>사기</span>
|
<span>사기</span>
|
||||||
<input
|
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
|
||||||
v-model.number="general.atmos"
|
|
||||||
type="number"
|
|
||||||
min="40"
|
|
||||||
:max="options.config.maxAtmosByWar"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>전특</span>
|
<span>전특</span>
|
||||||
@@ -308,30 +311,15 @@ const officerLevelOptions = [
|
|||||||
<div class="form-row buff-row">
|
<div class="form-row buff-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 회피</span>
|
<span>상대 회피</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warAvoidRatioOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 필살</span>
|
<span>상대 필살</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warCriticalRatioOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 계략</span>
|
<span>상대 계략</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,6 +379,11 @@ const officerLevelOptions = [
|
|||||||
color: #f0b6b6;
|
color: #f0b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.form-block {
|
.form-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<RouterLink
|
||||||
class="map-city"
|
class="map-city"
|
||||||
|
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||||
:class="[
|
:class="[
|
||||||
`state-${props.city.stateClass}`,
|
`state-${props.city.stateClass}`,
|
||||||
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
||||||
@@ -45,20 +46,22 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
@click.stop="emit('select', props.city.id)"
|
@click.stop="emit('select', props.city.id)"
|
||||||
>
|
>
|
||||||
<div
|
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||||
class="city-dot"
|
|
||||||
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
|
|
||||||
>
|
|
||||||
<span v-if="props.city.isCapital" class="capital" />
|
<span v-if="props.city.isCapital" class="capital" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="props.city.state > 0"
|
v-if="props.city.state > 0"
|
||||||
class="city-state"
|
class="city-state"
|
||||||
:class="`state-${props.city.stateClass}`"
|
:class="`state-${props.city.stateClass}`"
|
||||||
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }"
|
:style="{
|
||||||
|
width: `${stateSize}px`,
|
||||||
|
height: `${stateSize}px`,
|
||||||
|
left: `${stateOffset}px`,
|
||||||
|
top: `${stateOffset}px`,
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
||||||
</div>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: rgba(232, 221, 196, 0.8);
|
color: rgba(232, 221, 196, 0.8);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-dot {
|
.city-dot {
|
||||||
|
|||||||
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<RouterLink
|
||||||
class="city-base"
|
class="city-base"
|
||||||
|
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||||
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
||||||
:style="cityBaseStyle"
|
:style="cityBaseStyle"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
|
|||||||
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
||||||
<img :src="stateIcon" />
|
<img :src="stateIcon" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: rgba(232, 221, 196, 0.9);
|
color: rgba(232, 221, 196, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-bg {
|
.city-bg {
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
|
||||||
import type { MessageType } from '@sammo-ts/logic';
|
import type { MessageType } from '@sammo-ts/logic';
|
||||||
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
|
import MessagePlate from './MessagePlate.vue';
|
||||||
|
|
||||||
|
interface MessageTarget {
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
|
nationName: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageEntry {
|
interface MessageEntry {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
time: string;
|
time: string;
|
||||||
msgType: MessageType;
|
msgType: MessageType;
|
||||||
|
src: MessageTarget;
|
||||||
|
dest: MessageTarget | null;
|
||||||
option?: Record<string, unknown> | null;
|
option?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +28,22 @@ interface MessageBucket {
|
|||||||
public: MessageEntry[];
|
public: MessageEntry[];
|
||||||
national: MessageEntry[];
|
national: MessageEntry[];
|
||||||
diplomacy: MessageEntry[];
|
diplomacy: MessageEntry[];
|
||||||
|
permission: number;
|
||||||
|
latestRead: {
|
||||||
|
private: number;
|
||||||
|
diplomacy: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailboxGroup {
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
options: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -23,7 +51,10 @@ const props = defineProps<{
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
targetMailbox: number;
|
targetMailbox: number;
|
||||||
draftText: string;
|
draftText: string;
|
||||||
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
|
mailboxGroups: MailboxGroup[];
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
canRespondDiplomacy: boolean;
|
canRespondDiplomacy: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -34,213 +65,390 @@ const emit = defineEmits<{
|
|||||||
(event: 'refresh'): void;
|
(event: 'refresh'): void;
|
||||||
(event: 'load-older', type: MessageType): void;
|
(event: 'load-older', type: MessageType): void;
|
||||||
(event: 'respond', messageId: number, response: boolean): void;
|
(event: 'respond', messageId: number, response: boolean): void;
|
||||||
|
(event: 'read-latest', type: 'private' | 'diplomacy', messageId: number): void;
|
||||||
|
(event: 'delete', messageId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const messageTabs: Array<{ key: MessageType; label: string }> = [
|
const sections: Array<{ type: MessageType; label: string; className: string }> = [
|
||||||
{ key: 'public', label: '전체' },
|
{ type: 'public', label: '전체 메시지', className: 'PublicTalk' },
|
||||||
{ key: 'national', label: '국가' },
|
{ type: 'national', label: '국가 메시지', className: 'NationalTalk' },
|
||||||
{ key: 'private', label: '개인' },
|
{ type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
|
||||||
{ key: 'diplomacy', label: '외교' },
|
{ type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeTab = ref<MessageType>('public');
|
const visibleLimits = reactive<Record<MessageType, number>>({
|
||||||
|
public: Number.POSITIVE_INFINITY,
|
||||||
const activeMessages = computed(() => {
|
national: Number.POSITIVE_INFINITY,
|
||||||
if (!props.messages) {
|
private: Number.POSITIVE_INFINITY,
|
||||||
return [] as MessageEntry[];
|
diplomacy: Number.POSITIVE_INFINITY,
|
||||||
}
|
|
||||||
return props.messages[activeTab.value] ?? [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ?? [];
|
||||||
|
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
|
||||||
|
|
||||||
|
const permission = computed(() => props.messages?.permission ?? -1);
|
||||||
|
|
||||||
const setMailbox = (value: string) => {
|
const setMailbox = (value: string) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
|
const submit = () => {
|
||||||
message.msgType === 'diplomacy' &&
|
if (!props.draftText.trim()) {
|
||||||
(message.option?.action === 'noAggression' ||
|
emit('refresh');
|
||||||
message.option?.action === 'cancelNA' ||
|
|
||||||
message.option?.action === 'stopWar');
|
|
||||||
|
|
||||||
const respond = (messageId: number, response: boolean) => {
|
|
||||||
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
emit('send');
|
||||||
|
};
|
||||||
|
|
||||||
|
const newestIncomingId = (type: 'private' | 'diplomacy'): number =>
|
||||||
|
bucket(type)
|
||||||
|
.filter((message) => message.src.generalId !== props.generalId)
|
||||||
|
.reduce((latest, message) => Math.max(latest, message.id), 0);
|
||||||
|
|
||||||
|
const canMarkRead = (type: 'private' | 'diplomacy'): boolean => {
|
||||||
|
if (!props.messages) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newest = newestIncomingId(type);
|
||||||
|
return newest > props.messages.latestRead[type];
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRead = (type: 'private' | 'diplomacy') => {
|
||||||
|
const messageId = newestIncomingId(type);
|
||||||
|
if (messageId > 0) {
|
||||||
|
emit('read-latest', type, messageId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSectionMailbox = (type: MessageType) => {
|
||||||
|
if (type === 'public') {
|
||||||
|
emit('update:targetMailbox', 9999);
|
||||||
|
} else if (type === 'national') {
|
||||||
|
emit('update:targetMailbox', 9000 + props.nationId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReplyTarget = (type: MessageType, target: MessageTarget) => {
|
||||||
|
const mailbox =
|
||||||
|
(type === 'diplomacy' || type === 'national') && target.nationId !== props.nationId
|
||||||
|
? 9000 + target.nationId
|
||||||
|
: target.generalId;
|
||||||
|
if (mailbox > 0) {
|
||||||
|
emit('update:targetMailbox', mailbox);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fold = (type: MessageType) => {
|
||||||
|
if (bucket(type).length >= 10) {
|
||||||
|
visibleLimits[type] = 10;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forwardResponse = (messageId: number, response: boolean) => {
|
||||||
emit('respond', messageId, response);
|
emit('respond', messageId, response);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-panel">
|
<div class="MessagePanel">
|
||||||
<div class="message-input">
|
<div class="MessageInputForm">
|
||||||
<select
|
<div id="mailbox_list-col">
|
||||||
class="message-select"
|
<select
|
||||||
:value="targetMailbox"
|
id="mailbox_list"
|
||||||
@change="setMailbox(($event.target as HTMLSelectElement).value)"
|
class="message-select"
|
||||||
>
|
:value="targetMailbox"
|
||||||
<option
|
aria-label="메시지 수신 대상"
|
||||||
v-for="option in mailboxOptions"
|
@change="setMailbox(($event.target as HTMLSelectElement).value)"
|
||||||
:key="option.label"
|
|
||||||
:value="option.value"
|
|
||||||
:disabled="option.disabled"
|
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<optgroup
|
||||||
</option>
|
v-for="group in mailboxGroups"
|
||||||
</select>
|
:key="group.label"
|
||||||
<input
|
:label="group.label"
|
||||||
class="message-text"
|
:style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
|
||||||
type="text"
|
>
|
||||||
maxlength="99"
|
<option
|
||||||
:value="draftText"
|
v-for="option in group.options"
|
||||||
placeholder="메시지 입력"
|
:key="`${group.label}-${option.value}`"
|
||||||
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
|
:value="option.value"
|
||||||
@keydown.enter="emit('send')"
|
:disabled="option.disabled"
|
||||||
/>
|
:style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
|
||||||
<button class="message-send" @click="emit('send')">전송</button>
|
>
|
||||||
</div>
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
<div class="message-tabs">
|
</optgroup>
|
||||||
<button
|
</select>
|
||||||
v-for="tab in messageTabs"
|
</div>
|
||||||
:key="tab.key"
|
<div id="msg_input-col">
|
||||||
:class="{ active: activeTab === tab.key }"
|
<input
|
||||||
@click="activeTab = tab.key"
|
class="message-text"
|
||||||
>
|
type="text"
|
||||||
{{ tab.label }}
|
maxlength="99"
|
||||||
</button>
|
:value="draftText"
|
||||||
<button class="refresh" @click="emit('refresh')">갱신</button>
|
aria-label="메시지 입력"
|
||||||
</div>
|
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
|
||||||
|
@keydown.enter="submit"
|
||||||
<div v-if="props.loading">
|
/>
|
||||||
<SkeletonLines :lines="4" />
|
</div>
|
||||||
</div>
|
<div id="msg_submit-col">
|
||||||
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
|
<button class="message-send" type="button" @click="submit">서신전달&갱신</button>
|
||||||
<div v-else class="message-list">
|
|
||||||
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="message in activeMessages" :key="message.id" class="message-item">
|
|
||||||
<div class="text">{{ message.text }}</div>
|
|
||||||
<div v-if="isDiplomacyPrompt(message)" class="message-response">
|
|
||||||
<button class="accept" :disabled="!canRespondDiplomacy" @click="respond(message.id, true)">
|
|
||||||
수락
|
|
||||||
</button>
|
|
||||||
<button class="decline" :disabled="!canRespondDiplomacy" @click="respond(message.id, false)">
|
|
||||||
거절
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="time">{{ message.time }}</div>
|
|
||||||
</div>
|
|
||||||
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !messages" class="message-loading">
|
||||||
|
<SkeletonLines :lines="4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.type"
|
||||||
|
:class="['message-section', section.className]"
|
||||||
|
:data-message-type="section.type"
|
||||||
|
>
|
||||||
|
<div class="stickyAnchor"></div>
|
||||||
|
<header class="BoardHeader">
|
||||||
|
<div class="header-label">{{ section.label }}</div>
|
||||||
|
<button
|
||||||
|
v-if="section.type === 'public' || section.type === 'national'"
|
||||||
|
class="btn-more-small action-primary"
|
||||||
|
type="button"
|
||||||
|
@click="setSectionMailbox(section.type)"
|
||||||
|
>
|
||||||
|
↩ 여기로
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-more-small action-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canMarkRead(section.type)"
|
||||||
|
@click="markRead(section.type)"
|
||||||
|
>
|
||||||
|
모두 읽음
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="bucket(section.type).length === 0" class="empty-message">메시지가 없습니다.</div>
|
||||||
|
<div v-else class="MessageList">
|
||||||
|
<MessagePlate
|
||||||
|
v-for="message in visibleMessages(section.type)"
|
||||||
|
:key="message.id"
|
||||||
|
:message="message"
|
||||||
|
:general-id="generalId"
|
||||||
|
:general-name="generalName"
|
||||||
|
:nation-id="nationId"
|
||||||
|
:permission="permission"
|
||||||
|
:can-respond-diplomacy="canRespondDiplomacy"
|
||||||
|
@set-target="setReplyTarget"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
@respond="forwardResponse"
|
||||||
|
/>
|
||||||
|
<div class="Actions">
|
||||||
|
<button class="fold-message" type="button" @click="fold(section.type)">접기</button>
|
||||||
|
<button class="load-older" type="button" @click="emit('load-older', section.type)">
|
||||||
|
이전 메시지 불러오기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message-panel {
|
.MessagePanel {
|
||||||
display: flex;
|
color: #fff;
|
||||||
flex-direction: column;
|
font-size: 14px;
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-input {
|
.MessageInputForm {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr) minmax(0, 1fr);
|
||||||
|
grid-template-areas: 'mailbox input submit';
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
#mailbox_list-col {
|
||||||
|
grid-area: mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg_input-col {
|
||||||
|
grid-area: input;
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg_submit-col {
|
||||||
|
grid-area: submit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mailbox_list-col,
|
||||||
|
#msg_input-col,
|
||||||
|
#msg_submit-col {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(90px, 120px) 1fr auto;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-select,
|
.message-select,
|
||||||
|
.message-text,
|
||||||
|
.message-send {
|
||||||
|
height: 35.5px;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-select {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #212529;
|
||||||
|
padding: 4px 30px 4px 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.message-text {
|
.message-text {
|
||||||
background: rgba(16, 16, 16, 0.8);
|
width: 100%;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
background-color: #fff;
|
||||||
color: inherit;
|
padding: 4px 8px;
|
||||||
padding: 6px;
|
color: #212529;
|
||||||
font-size: 0.75rem;
|
}
|
||||||
|
|
||||||
|
.message-send,
|
||||||
|
.action-primary {
|
||||||
|
background-color: #337ab7;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-send {
|
.message-send {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-tabs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-tabs button {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.7rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-tabs button.active {
|
.message-send:hover {
|
||||||
background: rgba(201, 164, 90, 0.2);
|
background-color: #375a7f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-tabs .refresh {
|
.message-send:focus,
|
||||||
margin-left: auto;
|
.message-send:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
outline-width: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-loading {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BoardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
min-height: 25px;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
|
outline: 1px solid gray;
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item {
|
.header-label {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
flex: 1;
|
||||||
padding: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item .time {
|
.btn-more-small {
|
||||||
margin-top: 4px;
|
margin: 1px;
|
||||||
font-size: 0.65rem;
|
border: 1px solid transparent;
|
||||||
color: rgba(232, 221, 196, 0.6);
|
border-radius: 3px;
|
||||||
}
|
padding: 2px 6px;
|
||||||
|
font-size: 11.2px;
|
||||||
.message-response {
|
line-height: 1.5;
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-response button {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding: 3px 10px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-response .accept {
|
.action-secondary {
|
||||||
color: #8fd18f;
|
border-color: #6c757d;
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-response .decline {
|
.btn-more-small:disabled {
|
||||||
color: #e09a9a;
|
cursor: default;
|
||||||
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-response button:disabled {
|
.empty-message {
|
||||||
cursor: not-allowed;
|
min-height: 22px;
|
||||||
opacity: 0.5;
|
}
|
||||||
|
|
||||||
|
.MessageList {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Actions {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-message,
|
||||||
|
.load-older {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-message {
|
||||||
|
background-color: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-older {
|
.load-older {
|
||||||
border: 1px dashed rgba(201, 164, 90, 0.3);
|
background-color: #6c757d;
|
||||||
padding: 6px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
@media (min-width: 940px) {
|
||||||
color: rgba(232, 221, 196, 0.6);
|
.MessagePanel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MessageInputForm,
|
||||||
|
.message-loading {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.PublicTalk,
|
||||||
|
.PrivateTalk {
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MessageList {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 939.98px) {
|
||||||
|
.MessageInputForm {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 5;
|
||||||
|
top: 0;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
'mailbox submit'
|
||||||
|
'input input';
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
height: 33.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BoardHeader {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 4;
|
||||||
|
top: 62px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import type { MessageType } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
interface MessageTarget {
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
|
nationName: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageEntry {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
time: string;
|
||||||
|
msgType: MessageType;
|
||||||
|
src: MessageTarget;
|
||||||
|
dest: MessageTarget | null;
|
||||||
|
option?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message: MessageEntry;
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
|
permission: number;
|
||||||
|
canRespondDiplomacy: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'set-target', type: MessageType, target: MessageTarget): void;
|
||||||
|
(event: 'delete', messageId: number): void;
|
||||||
|
(event: 'respond', messageId: number, response: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const now = ref(Date.now());
|
||||||
|
let deleteTimer: number | null = null;
|
||||||
|
|
||||||
|
const destination = computed<MessageTarget>(
|
||||||
|
() =>
|
||||||
|
props.message.dest ?? {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 0,
|
||||||
|
nationName: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '/image/icons/default.jpg',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = computed(() => props.message.option?.invalid === true);
|
||||||
|
const hasAction = computed(() => typeof props.message.option?.action === 'string');
|
||||||
|
const nationDirection = computed(() => {
|
||||||
|
if (props.message.src.nationId === destination.value.nationId) {
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
return props.message.src.nationId === props.nationId ? 'src' : 'dest';
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseMessageTime = (): number => {
|
||||||
|
const normalized = props.message.time.includes('T')
|
||||||
|
? props.message.time
|
||||||
|
: `${props.message.time.replace(' ', 'T')}Z`;
|
||||||
|
return Date.parse(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletable = computed(() => {
|
||||||
|
if (invalid.value || hasAction.value || props.message.src.generalId !== props.generalId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (props.message.option?.deletable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sentAt = parseMessageTime();
|
||||||
|
return Number.isFinite(sentAt) && sentAt + 5 * 60 * 1000 > now.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleDeleteExpiry = () => {
|
||||||
|
const sentAt = parseMessageTime();
|
||||||
|
if (!Number.isFinite(sentAt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = sentAt + 5 * 60 * 1000 - Date.now();
|
||||||
|
if (delay <= 0) {
|
||||||
|
now.value = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteTimer = window.setTimeout(() => {
|
||||||
|
now.value = Date.now();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBright = (color: string): boolean => {
|
||||||
|
const match = /^#([0-9a-f]{6})$/i.exec(color);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const value = Number.parseInt(match[1]!, 16);
|
||||||
|
const red = (value >> 16) & 0xff;
|
||||||
|
const green = (value >> 8) & 0xff;
|
||||||
|
const blue = value & 0xff;
|
||||||
|
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconUrl = computed(() => {
|
||||||
|
const icon = props.message.src.icon?.trim();
|
||||||
|
if (!icon) {
|
||||||
|
return '/image/icons/default.jpg';
|
||||||
|
}
|
||||||
|
if (icon.startsWith('/') || /^https?:\/\//i.test(icon)) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
return `${import.meta.env.BASE_URL}${icon.replace(/^\/+/, '')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetClass = (target: MessageTarget) => ({
|
||||||
|
'msg-target': true,
|
||||||
|
'msg-bright': isBright(target.color),
|
||||||
|
'msg-dark': !isBright(target.color),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setTarget = (target: MessageTarget) => {
|
||||||
|
emit('set-target', props.message.msgType, target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDelete = () => {
|
||||||
|
if (!window.confirm('삭제하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('delete', props.message.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const respond = (response: boolean) => {
|
||||||
|
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('respond', props.message.id, response);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(scheduleDeleteExpiry);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (deleteTimer !== null) {
|
||||||
|
window.clearTimeout(deleteTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
:id="`msg_${message.id}`"
|
||||||
|
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
|
||||||
|
:data-id="message.id"
|
||||||
|
>
|
||||||
|
<div class="msg-icon">
|
||||||
|
<img class="general-icon" width="64" height="64" :src="iconUrl" :alt="message.src.generalName" />
|
||||||
|
</div>
|
||||||
|
<div class="msg-body">
|
||||||
|
<div class="msg-header">
|
||||||
|
<button v-if="deletable" class="delete-message" type="button" @click="requestDelete">❌</button>
|
||||||
|
|
||||||
|
<template v-if="message.msgType === 'private'">
|
||||||
|
<template v-if="message.src.generalId === generalId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"
|
||||||
|
>나</span
|
||||||
|
>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<button
|
||||||
|
:class="targetClass(destination)"
|
||||||
|
:style="{ backgroundColor: destination.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(destination)"
|
||||||
|
>
|
||||||
|
{{ destination.generalName }}:{{ destination.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
:class="targetClass(message.src)"
|
||||||
|
:style="{ backgroundColor: message.src.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(message.src)"
|
||||||
|
>
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
|
||||||
|
>나</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="message.msgType === 'national' && message.src.nationId === destination.nationId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-else-if="(message.msgType === 'national' || message.msgType === 'diplomacy') && permission >= 4"
|
||||||
|
>
|
||||||
|
<template v-if="message.src.nationId === nationId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<button
|
||||||
|
:class="targetClass(destination)"
|
||||||
|
:style="{ backgroundColor: destination.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(destination)"
|
||||||
|
>
|
||||||
|
{{ destination.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:class="targetClass(message.src)"
|
||||||
|
:style="{ backgroundColor: message.src.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(message.src)"
|
||||||
|
>
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="message.msgType === 'national' || message.msgType === 'diplomacy'">
|
||||||
|
<template v-if="message.src.nationId === nationId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
|
||||||
|
{{ destination.nationName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else-if="message.src.generalId !== generalId"
|
||||||
|
:class="targetClass(message.src)"
|
||||||
|
:style="{ backgroundColor: message.src.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(message.src)"
|
||||||
|
>
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="msg-time"><{{ 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>
|
||||||
@@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue';
|
|||||||
import GlobalInfoView from '../views/GlobalInfoView.vue';
|
import GlobalInfoView from '../views/GlobalInfoView.vue';
|
||||||
import CurrentCityView from '../views/CurrentCityView.vue';
|
import CurrentCityView from '../views/CurrentCityView.vue';
|
||||||
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
||||||
|
import NationSecretView from '../views/NationSecretView.vue';
|
||||||
import NationPersonnelView from '../views/NationPersonnelView.vue';
|
import NationPersonnelView from '../views/NationPersonnelView.vue';
|
||||||
import NationStratFinanView from '../views/NationStratFinanView.vue';
|
import NationStratFinanView from '../views/NationStratFinanView.vue';
|
||||||
import ChiefCenterView from '../views/ChiefCenterView.vue';
|
import ChiefCenterView from '../views/ChiefCenterView.vue';
|
||||||
@@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
|
|||||||
import TournamentView from '../views/TournamentView.vue';
|
import TournamentView from '../views/TournamentView.vue';
|
||||||
import BettingView from '../views/BettingView.vue';
|
import BettingView from '../views/BettingView.vue';
|
||||||
import MyPageView from '../views/MyPageView.vue';
|
import MyPageView from '../views/MyPageView.vue';
|
||||||
import MySettingsView from '../views/MySettingsView.vue';
|
|
||||||
import BoardView from '../views/BoardView.vue';
|
import BoardView from '../views/BoardView.vue';
|
||||||
import DiplomacyView from '../views/DiplomacyView.vue';
|
import DiplomacyView from '../views/DiplomacyView.vue';
|
||||||
import BestGeneralView from '../views/BestGeneralView.vue';
|
import BestGeneralView from '../views/BestGeneralView.vue';
|
||||||
@@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue';
|
|||||||
import YearbookView from '../views/YearbookView.vue';
|
import YearbookView from '../views/YearbookView.vue';
|
||||||
import NationBettingView from '../views/NationBettingView.vue';
|
import NationBettingView from '../views/NationBettingView.vue';
|
||||||
import NpcListView from '../views/NpcListView.vue';
|
import NpcListView from '../views/NpcListView.vue';
|
||||||
|
import TrafficView from '../views/TrafficView.vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -148,6 +149,12 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/nation/secret',
|
||||||
|
name: 'nation-secret',
|
||||||
|
component: NationSecretView,
|
||||||
|
meta: { requiresAuth: true, requiresGeneral: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/nation/personnel',
|
path: '/nation/personnel',
|
||||||
name: 'nation-personnel',
|
name: 'nation-personnel',
|
||||||
@@ -190,7 +197,6 @@ const routes = [
|
|||||||
component: BattleSimulatorView,
|
component: BattleSimulatorView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresGeneral: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -252,6 +258,11 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/traffic',
|
||||||
|
name: 'traffic',
|
||||||
|
component: TrafficView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/npc-list',
|
path: '/npc-list',
|
||||||
name: 'npc-list',
|
name: 'npc-list',
|
||||||
@@ -277,8 +288,7 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/my-settings',
|
path: '/my-settings',
|
||||||
name: 'my-settings',
|
redirect: '/my-page',
|
||||||
component: MySettingsView,
|
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
||||||
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
||||||
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
||||||
|
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
||||||
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
||||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
||||||
|
|
||||||
@@ -37,12 +38,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const mapLayout = ref<MapLayout | null>(null);
|
const mapLayout = ref<MapLayout | null>(null);
|
||||||
const commandTable = ref<CommandTable | null>(null);
|
const commandTable = ref<CommandTable | null>(null);
|
||||||
const messages = ref<MessageBundle | null>(null);
|
const messages = ref<MessageBundle | null>(null);
|
||||||
|
const messageContacts = ref<MessageContacts | null>(null);
|
||||||
const boardAccess = ref<BoardAccess | null>(null);
|
const boardAccess = ref<BoardAccess | null>(null);
|
||||||
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
|
|
||||||
const messageDraftText = ref('');
|
const messageDraftText = ref('');
|
||||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||||
|
let initializedMailboxGeneralId: number | null = null;
|
||||||
|
|
||||||
const general = computed(() => generalContext.value?.general ?? null);
|
const general = computed(() => generalContext.value?.general ?? null);
|
||||||
const city = computed(() => generalContext.value?.city ?? null);
|
const city = computed(() => generalContext.value?.city ?? null);
|
||||||
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
} as const;
|
} as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mailboxOptions = computed(() => {
|
const mailboxGroups = computed(() => {
|
||||||
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
|
type MailboxOption = {
|
||||||
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
|
label: string;
|
||||||
|
value: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
type MailboxGroup = {
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
options: MailboxOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownNationId = general.value?.nationId ?? 0;
|
||||||
|
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
|
||||||
|
const permission = messages.value?.permission ?? -1;
|
||||||
|
const contacts = messageContacts.value?.nation ?? [];
|
||||||
|
const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox);
|
||||||
|
const groups: MailboxGroup[] = [
|
||||||
|
{
|
||||||
|
label: '즐겨찾기',
|
||||||
|
color: '#000000',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '【 아국 메세지 】',
|
||||||
|
value: ownMailbox,
|
||||||
|
color: ownNation?.color ?? '#000000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '【 전체 메세지 】',
|
||||||
|
value: MESSAGE_MAILBOX_PUBLIC,
|
||||||
|
color: '#000000',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (nationId.value) {
|
|
||||||
options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value });
|
if (permission >= 4) {
|
||||||
} else {
|
groups.push({
|
||||||
options.push({ label: '국가', value: -1, disabled: true });
|
label: '외교메시지',
|
||||||
|
color: '#000000',
|
||||||
|
options: contacts
|
||||||
|
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
|
||||||
|
.map((nation) => ({
|
||||||
|
label: nation.name,
|
||||||
|
value: nation.mailbox,
|
||||||
|
color: nation.color,
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
options.push({ label: '외교', value: -2, disabled: true });
|
|
||||||
options.push({ label: '개인', value: -3, disabled: true });
|
const sortedContacts = [...contacts].sort((left, right) => {
|
||||||
return options;
|
if (left.mailbox === ownMailbox) return -1;
|
||||||
|
if (right.mailbox === ownMailbox) return 1;
|
||||||
|
return left.mailbox - right.mailbox;
|
||||||
|
});
|
||||||
|
for (const nation of sortedContacts) {
|
||||||
|
const options = [...nation.general]
|
||||||
|
.filter(([id]) => id !== generalId.value)
|
||||||
|
.sort((left, right) => left[1].localeCompare(right[1], 'ko'))
|
||||||
|
.map(([id, name, flags]) => {
|
||||||
|
const ruler = Boolean(flags & 1);
|
||||||
|
const ambassador = Boolean(flags & 4);
|
||||||
|
return {
|
||||||
|
label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name,
|
||||||
|
value: id,
|
||||||
|
disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox,
|
||||||
|
color: nation.color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (options.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusLine = computed(() => {
|
const statusLine = computed(() => {
|
||||||
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
context.general.nationId > 0 && context.general.officerLevel >= 5
|
context.general.nationId > 0 && context.general.officerLevel >= 5
|
||||||
? trpc.turns.reserved.getNation.query({ generalId: id })
|
? trpc.turns.reserved.getNation.query({ generalId: id })
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([
|
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
|
||||||
layoutPromise,
|
await Promise.all([
|
||||||
trpc.lobby.info.query(),
|
layoutPromise,
|
||||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
trpc.lobby.info.query(),
|
||||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||||
trpc.messages.getRecent.query({ generalId: id }),
|
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||||
trpc.board.getAccess.query(),
|
trpc.messages.getRecent.query({ generalId: id }),
|
||||||
generalTurnsPromise,
|
trpc.messages.getContacts.query({ generalId: id }),
|
||||||
nationTurnsPromise,
|
trpc.board.getAccess.query(),
|
||||||
]);
|
generalTurnsPromise,
|
||||||
|
nationTurnsPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
mapLayout.value = layout;
|
mapLayout.value = layout;
|
||||||
lobbyInfo.value = lobby;
|
lobbyInfo.value = lobby;
|
||||||
worldMap.value = map;
|
worldMap.value = map;
|
||||||
commandTable.value = commands;
|
commandTable.value = commands;
|
||||||
messages.value = messageData;
|
messages.value = messageData;
|
||||||
|
messageContacts.value = contacts;
|
||||||
boardAccess.value = access;
|
boardAccess.value = access;
|
||||||
reservedGeneralTurns.value = generalTurns;
|
reservedGeneralTurns.value = generalTurns;
|
||||||
reservedNationTurns.value = nationTurns;
|
reservedNationTurns.value = nationTurns;
|
||||||
|
if (initializedMailboxGeneralId !== id) {
|
||||||
|
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||||
|
initializedMailboxGeneralId = id;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
messageDraftText.value = '';
|
||||||
await trpc.messages.send.mutate({
|
await trpc.messages.send.mutate({
|
||||||
generalId: id,
|
generalId: id,
|
||||||
mailbox,
|
mailbox,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
messageDraftText.value = '';
|
|
||||||
await refreshMessages();
|
await refreshMessages();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
@@ -260,6 +337,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id || messageId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await trpc.messages.readLatest.mutate({
|
||||||
|
generalId: id,
|
||||||
|
type,
|
||||||
|
messageId,
|
||||||
|
});
|
||||||
|
if (messages.value) {
|
||||||
|
messages.value = {
|
||||||
|
...messages.value,
|
||||||
|
latestRead: {
|
||||||
|
...messages.value.latestRead,
|
||||||
|
[type]: Math.max(messages.value.latestRead[type], messageId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMessage = async (messageId: number) => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await trpc.messages.delete.mutate({ generalId: id, messageId });
|
||||||
|
await refreshMessages();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setGeneralTurn = async (turnIndex: number, action: string) => {
|
const setGeneralTurn = async (turnIndex: number, action: string) => {
|
||||||
const id = generalId.value;
|
const id = generalId.value;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
selectedCity,
|
selectedCity,
|
||||||
commandTable,
|
commandTable,
|
||||||
messages,
|
messages,
|
||||||
|
messageContacts,
|
||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxOptions,
|
mailboxGroups,
|
||||||
statusLine,
|
statusLine,
|
||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
setRealtimeEnabled,
|
setRealtimeEnabled,
|
||||||
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
respondToMessage,
|
respondToMessage,
|
||||||
|
readLatestMessage,
|
||||||
|
deleteMessage,
|
||||||
setGeneralTurn,
|
setGeneralTurn,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
setNationTurn,
|
setNationTurn,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { getNpcColor } from '../utils/npcColor';
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
@@ -269,7 +268,26 @@ onMounted(() => {
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|
||||||
<PanelCard title="장수 정보">
|
<PanelCard title="장수 정보">
|
||||||
<GeneralBasicCard :general="selectedGeneral" :loading="loading" />
|
<SkeletonLines v-if="loading" :lines="5" />
|
||||||
|
<div v-else-if="selectedGeneral" class="battle-general-card">
|
||||||
|
<div class="battle-general-name">
|
||||||
|
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
|
||||||
|
</div>
|
||||||
|
<div class="battle-general-grid">
|
||||||
|
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
|
||||||
|
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
|
||||||
|
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
|
||||||
|
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
|
||||||
|
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
|
||||||
|
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
|
||||||
|
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
|
||||||
|
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
|
||||||
|
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
|
||||||
|
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
|
||||||
|
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
|
||||||
|
><strong>{{ selectedGeneral.warnum }}회</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="selectedGeneral" class="general-meta">
|
<div v-if="selectedGeneral" class="general-meta">
|
||||||
<div>최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
|
<div>최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
|
||||||
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
|
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
|
||||||
@@ -286,12 +304,7 @@ onMounted(() => {
|
|||||||
<SkeletonLines v-if="loading || logLoading" :lines="3" />
|
<SkeletonLines v-if="loading || logLoading" :lines="3" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
|
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
|
||||||
<div
|
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
|
||||||
v-for="entry in logs[type]"
|
|
||||||
:key="entry.id"
|
|
||||||
class="log-line"
|
|
||||||
v-html="entry.html"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,126 +316,237 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.battle-page {
|
.battle-page {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 500px;
|
||||||
|
max-width: 1000px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 0;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
position: relative;
|
||||||
|
min-height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 1.6rem;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
color: rgba(232, 221, 196, 0.7);
|
display: none;
|
||||||
margin-top: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-grid {
|
.layout-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: flex;
|
display: contents;
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-row {
|
.selector-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto;
|
grid-template-columns: 8.333% 33.333% 50% 8.333%;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-input {
|
.select-input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 6px 8px;
|
height: 36px;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
padding: 4px 6px;
|
||||||
background: rgba(12, 12, 12, 0.7);
|
border: 1px solid #777;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #303030;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: 0.85rem;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.5);
|
min-height: 32px;
|
||||||
background: transparent;
|
border: 1px solid #777;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #303030;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 6px 10px;
|
padding: 4px 8px;
|
||||||
font-size: 0.8rem;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.general-meta {
|
.general-meta {
|
||||||
margin-top: 10px;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
padding: 6px 8px;
|
||||||
color: rgba(232, 221, 196, 0.75);
|
color: #ccc;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-grid {
|
.battle-general-card {
|
||||||
|
min-height: 292px;
|
||||||
|
background-color: #172a52;
|
||||||
|
background-image: url('/image/game/back_blue.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-general-name {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #777;
|
||||||
|
background: rgba(220, 220, 220, 0.85);
|
||||||
|
color: #111;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-general-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.battle-general-grid > * {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-right: 1px solid #777;
|
||||||
|
border-bottom: 1px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-general-grid > span {
|
||||||
|
background-color: rgba(20, 75, 42, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-general-grid > strong {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-grid {
|
||||||
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-block {
|
.log-block {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
border: 1px solid #666;
|
||||||
padding: 8px;
|
padding: 0;
|
||||||
background: rgba(12, 12, 12, 0.6);
|
background: #111;
|
||||||
min-height: 160px;
|
min-height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-title {
|
.log-title {
|
||||||
font-weight: 600;
|
min-height: 34px;
|
||||||
margin-bottom: 6px;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 1px solid #666;
|
||||||
|
color: orange;
|
||||||
|
background: #252525;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-line {
|
.log-line {
|
||||||
padding: 4px 0;
|
padding: 2px 8px;
|
||||||
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
|
border-bottom: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.log-line:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: rgba(232, 221, 196, 0.6);
|
padding: 2px 8px;
|
||||||
font-size: 0.85rem;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #f08a5d;
|
padding: 5px 8px;
|
||||||
font-size: 0.9rem;
|
color: #ff7777;
|
||||||
|
border: 1px solid #a33;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
/* PanelCard is retained as a data wrapper, but its presentation follows the
|
||||||
|
flat bootstrap rows used by the reference page. */
|
||||||
|
:deep(.panel-card) {
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.stack:first-child :deep(.panel-card:first-child) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.stack:first-child :deep(.panel-card:first-child .panel-header) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.stack:first-child :deep(.panel-card:first-child .panel-body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.stack:nth-child(2) :deep(.panel-card),
|
||||||
|
.stack:nth-child(2) :deep(.panel-body) {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.stack:nth-child(2) :deep(.panel-header) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
:deep(.panel-header) {
|
||||||
|
min-height: 29px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
:deep(.panel-title) {
|
||||||
|
color: skyblue;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
:deep(.panel-header),
|
||||||
|
.log-title {
|
||||||
|
background-image: url('/image/game/back_green.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.battle-page {
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
.layout-grid {
|
.layout-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-row {
|
.selector-row {
|
||||||
|
grid-template-columns: 16.666% 25% 41.666% 16.666%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type BattleExport = {
|
|||||||
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
|
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
|
||||||
|
|
||||||
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
|
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
|
||||||
|
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
|
|||||||
const generalList = ref<GeneralListResponse | null>(null);
|
const generalList = ref<GeneralListResponse | null>(null);
|
||||||
const generalListLoading = ref(false);
|
const generalListLoading = ref(false);
|
||||||
const selectedGeneralId = ref<number | null>(null);
|
const selectedGeneralId = ref<number | null>(null);
|
||||||
|
const gameDefaults = ref<GeneralMeResponse>(null);
|
||||||
|
|
||||||
let generalIdSeed = 0;
|
let generalIdSeed = 0;
|
||||||
|
|
||||||
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
|
|||||||
try {
|
try {
|
||||||
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
|
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
|
||||||
options.value = context;
|
options.value = context;
|
||||||
|
gameDefaults.value = me;
|
||||||
year.value = context.world.currentYear;
|
year.value = context.world.currentYear;
|
||||||
month.value = context.world.currentMonth;
|
month.value = context.world.currentMonth;
|
||||||
repeatCnt.value = 1;
|
repeatCnt.value = 1;
|
||||||
@@ -283,6 +286,47 @@ const initializeDefaults = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
|
||||||
|
|
||||||
|
const applyGameEnvironment = () => {
|
||||||
|
if (!options.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = gameDefaults.value;
|
||||||
|
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
|
||||||
|
year.value = options.value.world.currentYear;
|
||||||
|
month.value = options.value.world.currentMonth;
|
||||||
|
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
|
||||||
|
defenderNation.type = attackerNation.type;
|
||||||
|
attackerNation.level = me?.nation?.level ?? 0;
|
||||||
|
defenderNation.level = attackerNation.level;
|
||||||
|
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
|
||||||
|
defenderNation.tech = attackerNation.tech;
|
||||||
|
attackerCity.level = me?.city?.level ?? 5;
|
||||||
|
defenderCity.level = attackerCity.level;
|
||||||
|
defenderCity.def = me?.city?.defence ?? 1000;
|
||||||
|
defenderCity.wall = me?.city?.wall ?? 1000;
|
||||||
|
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
|
||||||
|
defenderNation.isCapital = attackerNation.isCapital;
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyIndependentEnvironment = () => {
|
||||||
|
if (!options.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
|
||||||
|
year.value = options.value.world.startYear;
|
||||||
|
month.value = 1;
|
||||||
|
seed.value = '';
|
||||||
|
repeatCnt.value = 1;
|
||||||
|
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
|
||||||
|
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
|
||||||
|
attackerCity.level = 5;
|
||||||
|
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void initializeDefaults();
|
void initializeDefaults();
|
||||||
});
|
});
|
||||||
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSimulating.value = true;
|
isSimulating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
if (action === 'battle') {
|
||||||
|
battleResult.value = null;
|
||||||
|
}
|
||||||
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = buildBattlePayload(action);
|
const payload = buildBattlePayload(action);
|
||||||
const response = await trpc.battle.simulate.mutate(payload);
|
const response = await trpc.battle.simulate.mutate(payload);
|
||||||
const result =
|
const result =
|
||||||
'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId);
|
'payload' in response && response.payload
|
||||||
|
? response.payload
|
||||||
|
: await waitForSimulationResult(response.jobId);
|
||||||
|
|
||||||
if (!result.result) {
|
if (!result.result) {
|
||||||
error.value = result.reason || 'battle_failed';
|
error.value = result.reason || 'battle_failed';
|
||||||
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openImportModal = async (target: GeneralDraft) => {
|
const openImportModal = async (target: GeneralDraft) => {
|
||||||
|
if (!hasGameGeneral.value) {
|
||||||
|
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
importTarget.value = target;
|
importTarget.value = target;
|
||||||
importOpen.value = true;
|
importOpen.value = true;
|
||||||
if (!generalList.value) {
|
if (!generalList.value) {
|
||||||
@@ -801,47 +855,62 @@ const closeImportModal = () => {
|
|||||||
importTarget.value = null;
|
importTarget.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
|
||||||
|
const response = await trpc.battle.getGeneralDetail.query({ generalId });
|
||||||
|
applyGeneralExport(target, {
|
||||||
|
no: response.general.no,
|
||||||
|
name: response.general.name,
|
||||||
|
officerLevel: response.general.officer_level,
|
||||||
|
expLevel: response.general.explevel,
|
||||||
|
leadership: response.general.leadership,
|
||||||
|
strength: response.general.strength,
|
||||||
|
intel: response.general.intel,
|
||||||
|
horse: response.general.horse,
|
||||||
|
weapon: response.general.weapon,
|
||||||
|
book: response.general.book,
|
||||||
|
item: response.general.item,
|
||||||
|
injury: response.general.injury,
|
||||||
|
rice: response.general.rice,
|
||||||
|
personal: response.general.personal,
|
||||||
|
special2: response.general.special2,
|
||||||
|
crew: response.general.crew,
|
||||||
|
crewtype: response.general.crewtype,
|
||||||
|
atmos: response.general.atmos,
|
||||||
|
train: response.general.train,
|
||||||
|
dex1: response.general.dex1,
|
||||||
|
dex2: response.general.dex2,
|
||||||
|
dex3: response.general.dex3,
|
||||||
|
dex4: response.general.dex4,
|
||||||
|
dex5: response.general.dex5,
|
||||||
|
defenceTrain: response.general.defence_train,
|
||||||
|
warnum: response.general.warnum,
|
||||||
|
killnum: response.general.killnum,
|
||||||
|
killcrew: response.general.killcrew,
|
||||||
|
inheritBuff: createInheritBuff(),
|
||||||
|
});
|
||||||
|
target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMyGeneralToAttacker = async () => {
|
||||||
|
const generalId = gameDefaults.value?.general?.id;
|
||||||
|
if (!attackerGeneral.value || !generalId) {
|
||||||
|
error.value = '불러올 내 장수가 없습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
error.value = null;
|
||||||
|
await applyServerGeneral(attackerGeneral.value, generalId);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const confirmImport = async () => {
|
const confirmImport = async () => {
|
||||||
if (!importTarget.value || !selectedGeneralId.value) {
|
if (!importTarget.value || !selectedGeneralId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
|
await applyServerGeneral(importTarget.value, selectedGeneralId.value);
|
||||||
applyGeneralExport(importTarget.value, {
|
|
||||||
no: response.general.no,
|
|
||||||
name: response.general.name,
|
|
||||||
officerLevel: response.general.officer_level,
|
|
||||||
expLevel: response.general.explevel,
|
|
||||||
leadership: response.general.leadership,
|
|
||||||
strength: response.general.strength,
|
|
||||||
intel: response.general.intel,
|
|
||||||
horse: response.general.horse,
|
|
||||||
weapon: response.general.weapon,
|
|
||||||
book: response.general.book,
|
|
||||||
item: response.general.item,
|
|
||||||
injury: response.general.injury,
|
|
||||||
rice: response.general.rice,
|
|
||||||
personal: response.general.personal,
|
|
||||||
special2: response.general.special2,
|
|
||||||
crew: response.general.crew,
|
|
||||||
crewtype: response.general.crewtype,
|
|
||||||
atmos: response.general.atmos,
|
|
||||||
train: response.general.train,
|
|
||||||
dex1: response.general.dex1,
|
|
||||||
dex2: response.general.dex2,
|
|
||||||
dex3: response.general.dex3,
|
|
||||||
dex4: response.general.dex4,
|
|
||||||
dex5: response.general.dex5,
|
|
||||||
defenceTrain: response.general.defence_train,
|
|
||||||
warnum: response.general.warnum,
|
|
||||||
killnum: response.general.killnum,
|
|
||||||
killcrew: response.general.killcrew,
|
|
||||||
inheritBuff: createInheritBuff(),
|
|
||||||
});
|
|
||||||
importTarget.value.no =
|
|
||||||
importTarget.value === attackerGeneral.value
|
|
||||||
? 1
|
|
||||||
: resolveGeneralNo(response.general.no, importTarget.value.id);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
|
|||||||
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
||||||
{
|
{
|
||||||
label: '준 피해',
|
label: '준 피해',
|
||||||
value: battleResult.value.minKilled !== battleResult.value.maxKilled
|
value:
|
||||||
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
battleResult.value.minKilled !== battleResult.value.maxKilled
|
||||||
battleResult.value.maxKilled
|
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
||||||
)})`
|
battleResult.value.maxKilled
|
||||||
: formatNumber(battleResult.value.killed),
|
)})`
|
||||||
|
: formatNumber(battleResult.value.killed),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '받은 피해',
|
label: '받은 피해',
|
||||||
value: battleResult.value.minDead !== battleResult.value.maxDead
|
value:
|
||||||
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
battleResult.value.minDead !== battleResult.value.maxDead
|
||||||
battleResult.value.maxDead
|
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
||||||
)})`
|
battleResult.value.maxDead
|
||||||
: formatNumber(battleResult.value.dead),
|
)})`
|
||||||
|
: formatNumber(battleResult.value.dead),
|
||||||
},
|
},
|
||||||
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
|
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
|
||||||
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
|
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
|
||||||
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
|
||||||
|
<div>
|
||||||
|
<strong>게임 상태와 분리된 모의 계산</strong>
|
||||||
|
<p>
|
||||||
|
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 턴·DB·장수 상태를 변경하지
|
||||||
|
않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="notice-actions">
|
||||||
|
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
|
||||||
|
현재 게임 환경 적용
|
||||||
|
</button>
|
||||||
|
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
|
||||||
|
독립 기본값
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="!hasGameGeneral || !attackerGeneral"
|
||||||
|
@click="applyMyGeneralToAttacker"
|
||||||
|
>
|
||||||
|
내 장수를 출병자로
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
||||||
|
|
||||||
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
:options="options!"
|
:options="options!"
|
||||||
mode="attacker"
|
mode="attacker"
|
||||||
title="출병자 설정"
|
title="출병자 설정"
|
||||||
|
:can-import-server="hasGameGeneral"
|
||||||
@import="openImportModal(attackerGeneral!)"
|
@import="openImportModal(attackerGeneral!)"
|
||||||
@save="saveGeneral(attackerGeneral!)"
|
@save="saveGeneral(attackerGeneral!)"
|
||||||
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
||||||
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
:options="options!"
|
:options="options!"
|
||||||
mode="defender"
|
mode="defender"
|
||||||
:title="`수비자 설정 ${index + 1}`"
|
:title="`수비자 설정 ${index + 1}`"
|
||||||
|
:can-import-server="hasGameGeneral"
|
||||||
@import="openImportModal(defender)"
|
@import="openImportModal(defender)"
|
||||||
@save="saveGeneral(defender)"
|
@save="saveGeneral(defender)"
|
||||||
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
|
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
|
||||||
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="select-wrap">
|
<div v-else class="select-wrap">
|
||||||
<select v-model.number="selectedGeneralId">
|
<select v-model.number="selectedGeneralId">
|
||||||
<optgroup
|
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
|
||||||
v-for="group in generalGroups"
|
|
||||||
:key="group.nation.id"
|
|
||||||
:label="group.nation.name"
|
|
||||||
>
|
|
||||||
<option
|
<option
|
||||||
v-for="general in group.generals"
|
v-for="general in group.generals"
|
||||||
:key="general.id"
|
:key="general.id"
|
||||||
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
width: min(100%, 1000px);
|
||||||
|
margin: 0 auto;
|
||||||
padding-bottom: 30px;
|
padding-bottom: 30px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
|
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
|
||||||
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.independence-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(112, 170, 141, 0.45);
|
||||||
|
background: rgba(18, 52, 40, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.independence-notice strong {
|
||||||
|
color: #bfe2cd;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.independence-notice p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: rgba(221, 239, 228, 0.75);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1230,6 +1355,11 @@ button {
|
|||||||
background: rgba(16, 16, 16, 0.6);
|
background: rgba(16, 16, 16, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #f5b7b1;
|
color: #f5b7b1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -1369,5 +1499,10 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.independence-notice {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type RankEntry = {
|
type RankEntry = {
|
||||||
@@ -11,7 +13,6 @@ type RankEntry = {
|
|||||||
fgColor: string;
|
fgColor: string;
|
||||||
picture: string | null;
|
picture: string | null;
|
||||||
imageServer: number;
|
imageServer: number;
|
||||||
value: number;
|
|
||||||
printValue: string;
|
printValue: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,18 +22,17 @@ type RankSection = {
|
|||||||
entries: RankEntry[];
|
entries: RankEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UniqueOwner = {
|
type UniqueItemEntry = {
|
||||||
id: number;
|
itemKey: string;
|
||||||
name: string;
|
itemName: string;
|
||||||
nationName: string;
|
itemInfo: string;
|
||||||
bgColor: string;
|
owner: Omit<RankEntry, 'ownerName' | 'printValue'>;
|
||||||
fgColor: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type UniqueItemSection = {
|
type UniqueItemSection = {
|
||||||
title: string;
|
title: string;
|
||||||
slot: string;
|
slot: string;
|
||||||
owners: UniqueOwner[];
|
entries: UniqueItemEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type BestGeneralPayload = {
|
type BestGeneralPayload = {
|
||||||
@@ -41,12 +41,26 @@ type BestGeneralPayload = {
|
|||||||
uniqueItems: UniqueItemSection[];
|
uniqueItems: UniqueItemSection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const viewMode = ref<'user' | 'npc'>('user');
|
const viewMode = ref<'user' | 'npc'>('user');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const data = ref<BestGeneralPayload | null>(null);
|
const data = ref<BestGeneralPayload | null>(null);
|
||||||
|
|
||||||
const refresh = async () => {
|
const imageUrl = (entry: { picture: string | null; imageServer: number }): string => {
|
||||||
|
const picture = entry.picture?.trim() || 'default.jpg';
|
||||||
|
return entry.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePage = async (): Promise<void> => {
|
||||||
|
if (window.opener) {
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
try {
|
try {
|
||||||
@@ -58,8 +72,6 @@ const refresh = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyLabel = computed(() => (loading.value ? '불러오는 중...' : '표시할 데이터가 없습니다.'));
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
});
|
});
|
||||||
@@ -70,61 +82,280 @@ watch(viewMode, () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="main-page">
|
<main id="best-general-container" class="legacy-ranking-page legacy-bg0">
|
||||||
<header class="page-header">
|
<div class="legacy-ranking-title">
|
||||||
<div>
|
명 장 일 람<br />
|
||||||
<h1 class="page-title">명장일람</h1>
|
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||||
<p class="page-subtitle">전장 기록을 기준으로 장수 순위를 확인합니다.</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="ghost" :class="{ active: viewMode === 'user' }" @click="viewMode = 'user'">
|
|
||||||
유저 보기
|
|
||||||
</button>
|
|
||||||
<button class="ghost" :class="{ active: viewMode === 'npc' }" @click="viewMode = 'npc'">
|
|
||||||
NPC 보기
|
|
||||||
</button>
|
|
||||||
<button class="ghost" @click="refresh">새로고침</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
|
<div class="view-selector" role="group" aria-label="장수 유형">
|
||||||
<div v-else-if="!data" class="placeholder">{{ emptyLabel }}</div>
|
<button
|
||||||
|
class="legacy-button"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="viewMode === 'user'"
|
||||||
|
@click="viewMode = 'user'"
|
||||||
|
>
|
||||||
|
유저 보기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="legacy-button"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="viewMode === 'npc'"
|
||||||
|
@click="viewMode = 'npc'"
|
||||||
|
>
|
||||||
|
NPC 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section v-if="data" class="grid gap-4">
|
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
|
||||||
<div v-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
<div v-else-if="loading && !data" class="legacy-message">불러오는 중...</div>
|
||||||
<h2 class="text-base font-semibold mb-3">{{ section.title }}</h2>
|
|
||||||
<div v-if="section.entries.length === 0" class="text-xs text-zinc-500">{{ emptyLabel }}</div>
|
<section v-if="data" class="ranking-sections" :aria-busy="loading">
|
||||||
<ul v-else class="space-y-2">
|
<article v-for="section in data.sections" :key="section.title" class="rankView legacy-bg0">
|
||||||
<li
|
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
|
||||||
v-for="entry in section.entries"
|
<ul>
|
||||||
:key="entry.id"
|
<li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.id}:${rank}`">
|
||||||
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm"
|
<div class="hall-rank legacy-bg2">{{ rank + 1 }}위</div>
|
||||||
>
|
<div class="hall-img">
|
||||||
<div class="flex items-center gap-2">
|
<img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
|
||||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: entry.bgColor }" />
|
|
||||||
<span class="font-semibold">{{ entry.name }}</span>
|
|
||||||
<span class="text-xs text-zinc-400">{{ entry.nationName }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-200">{{ entry.printValue }}</div>
|
<div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
|
||||||
|
{{ entry.nationName || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="hall-name" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
|
||||||
|
<span>{{ entry.name || '-' }}</span>
|
||||||
|
<small v-if="entry.ownerName">({{ entry.ownerName }})</small>
|
||||||
|
</div>
|
||||||
|
<div class="hall-value">{{ entry.printValue }}</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
|
<article v-for="section in data.uniqueItems" :key="section.slot" class="rankView legacy-bg0">
|
||||||
|
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(entry, index) in section.entries"
|
||||||
|
:key="`${entry.itemKey}:${index}`"
|
||||||
|
class="no-value"
|
||||||
|
>
|
||||||
|
<div class="hall-rank legacy-bg2 item-name" :title="entry.itemInfo">{{ entry.itemName }}</div>
|
||||||
|
<div class="hall-img">
|
||||||
|
<img
|
||||||
|
class="generalIcon"
|
||||||
|
:src="imageUrl(entry.owner)"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
:alt="entry.owner.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hall-nation"
|
||||||
|
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
|
||||||
|
>
|
||||||
|
{{ entry.owner.nationName || '-' }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hall-name"
|
||||||
|
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
|
||||||
|
>
|
||||||
|
<span>{{ entry.owner.name || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="data" class="mt-6 bg-zinc-900 border border-zinc-800 rounded p-4">
|
<div class="legacy-ranking-bottom">
|
||||||
<h2 class="text-base font-semibold mb-3">유니크 아이템 소유자</h2>
|
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||||
<div class="grid md:grid-cols-2 gap-4">
|
</div>
|
||||||
<div v-for="item in data.uniqueItems" :key="item.title" class="bg-zinc-950 border border-zinc-800 rounded p-3">
|
<footer class="legacy-banner">
|
||||||
<h3 class="text-sm font-semibold mb-2">{{ item.title }}</h3>
|
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
|
||||||
<ul class="space-y-1 text-xs">
|
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||||
<li v-for="owner in item.owners" :key="owner.id" class="flex items-center gap-2">
|
</footer>
|
||||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: owner.bgColor }" />
|
|
||||||
<span>{{ owner.name }}</span>
|
|
||||||
<span class="text-zinc-500">{{ owner.nationName }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:global(body) {
|
||||||
|
min-width: 500px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-ranking-page {
|
||||||
|
width: 500px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto 100px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-ranking-title,
|
||||||
|
.legacy-ranking-bottom {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-ranking-title {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector {
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector .legacy-button + .legacy-button {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector .legacy-button[aria-pressed='true'] {
|
||||||
|
border-style: inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
background: #375a7f;
|
||||||
|
padding: 5.25px 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-button:hover,
|
||||||
|
.legacy-button:focus,
|
||||||
|
.legacy-button:active {
|
||||||
|
background: #6b6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-button:focus-visible {
|
||||||
|
outline: revert;
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-message {
|
||||||
|
border: 1px solid gray;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-message.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-banner {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-banner a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-sections {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankView {
|
||||||
|
position: relative;
|
||||||
|
margin: auto;
|
||||||
|
outline: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankType {
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
padding: 2px;
|
||||||
|
font-size: calc(19px + 0.784615vw);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankView ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: -1px 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankView li {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 100px;
|
||||||
|
width: 100px;
|
||||||
|
min-height: 149px;
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid gray;
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankView li.no-value {
|
||||||
|
min-height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-rank,
|
||||||
|
.hall-nation,
|
||||||
|
.hall-value {
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-rank.item-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-img {
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generalIcon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-nation,
|
||||||
|
.hall-name {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-name {
|
||||||
|
display: flex;
|
||||||
|
height: 28px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-name small {
|
||||||
|
font-size: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-value {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 3px 0;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1000px) {
|
||||||
|
:global(body) {
|
||||||
|
min-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-ranking-page {
|
||||||
|
width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankType {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,32 +1,110 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||||
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
|
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
|
||||||
|
type General = Result['generals'][number];
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const data = ref<Result | null>(null);
|
const data = ref<Result | null>(null);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const selected = ref<number>();
|
const selected = ref<number>();
|
||||||
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
let loadSequence = 0;
|
||||||
|
|
||||||
|
const parseCityId = (): number | undefined => {
|
||||||
|
const raw = route.query.cityId ?? route.query.citylist;
|
||||||
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
|
||||||
|
const cityId = Number(value);
|
||||||
|
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const load = async (cityId?: number) => {
|
const load = async (cityId?: number) => {
|
||||||
|
const sequence = ++loadSequence;
|
||||||
try {
|
try {
|
||||||
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
||||||
selected.value = data.value.city.id;
|
if (sequence !== loadSequence) return;
|
||||||
|
data.value = result;
|
||||||
|
selected.value = result.city.id;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (sequence !== loadSequence) return;
|
||||||
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
|
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.cityId, route.query.citylist],
|
||||||
|
() => void load(parseCityId()),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectCity = async () => {
|
||||||
|
if (!selected.value) return;
|
||||||
|
await router.push({ name: 'current-city', query: { cityId: selected.value } });
|
||||||
|
};
|
||||||
|
|
||||||
const city = computed(() => data.value?.city);
|
const city = computed(() => data.value?.city);
|
||||||
onMounted(() => void load());
|
const summary = computed(() => data.value?.forceSummary);
|
||||||
|
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
||||||
|
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
|
||||||
|
const populationRate = computed(() => {
|
||||||
|
if (!city.value || city.value.population === null) return '?';
|
||||||
|
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
|
||||||
|
});
|
||||||
|
const contrastColors = new Set([
|
||||||
|
'',
|
||||||
|
'#330000',
|
||||||
|
'#FF0000',
|
||||||
|
'#800000',
|
||||||
|
'#A0522D',
|
||||||
|
'#FF6347',
|
||||||
|
'#808000',
|
||||||
|
'#008000',
|
||||||
|
'#2E8B57',
|
||||||
|
'#008080',
|
||||||
|
'#6495ED',
|
||||||
|
'#0000FF',
|
||||||
|
'#000080',
|
||||||
|
'#483D8B',
|
||||||
|
'#7B68EE',
|
||||||
|
'#800080',
|
||||||
|
'#A9A9A9',
|
||||||
|
'#000000',
|
||||||
|
]);
|
||||||
|
const cityTitleStyle = computed(() => {
|
||||||
|
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
|
||||||
|
return {
|
||||||
|
backgroundColor,
|
||||||
|
color: contrastColors.has(backgroundColor) ? '#FFFFFF' : '#000000',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const woundedStat = (value: number, injury: number) =>
|
||||||
|
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
|
||||||
|
const defenceTrainText = (value: number | null) => {
|
||||||
|
if (value === null) return '?';
|
||||||
|
if (value === 999) return '×';
|
||||||
|
if (value >= 90) return '☆';
|
||||||
|
if (value >= 80) return '◎';
|
||||||
|
if (value >= 60) return '○';
|
||||||
|
return '△';
|
||||||
|
};
|
||||||
|
const generalImage = (general: General) => {
|
||||||
|
const picture = general.picture ?? 'default.jpg';
|
||||||
|
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="city-page">
|
<main class="city-page">
|
||||||
<table class="legacy-table legacy-bg0 center">
|
<table class="legacy-table legacy-bg0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>도 시 정 보<br /><RouterLink to="/">돌아가기</RouterLink></td>
|
<td>도 시 정 보<br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -34,32 +112,57 @@ onMounted(() => void load());
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
도시선택 :
|
<form @submit.prevent="selectCity">
|
||||||
<select v-model.number="selected" @change="load(selected)">
|
<div>
|
||||||
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
도시선택 :
|
||||||
【{{ option.name.padEnd(4, '_') }}】{{
|
<select id="citySelector" v-model.number="selected" @change="selectCity">
|
||||||
option.nationId === data?.me.nationId
|
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
||||||
? '본국'
|
【{{ option.name.padEnd(4, '_') }}】{{
|
||||||
: option.nationId === 0
|
option.nationId === data?.me.nationId
|
||||||
? '공백지'
|
? '본국'
|
||||||
: '타국'
|
: option.nationId === 0
|
||||||
}}
|
? '공백지'
|
||||||
</option>
|
: '타국'
|
||||||
</select>
|
}}
|
||||||
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||||
<template v-if="data && city">
|
<template v-if="data && city">
|
||||||
<table class="legacy-table legacy-bg2 stats">
|
<table class="legacy-table legacy-bg0 back-row">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="11" class="city-title">
|
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table class="legacy-table legacy-bg2 stats">
|
||||||
|
<colgroup>
|
||||||
|
<col class="label-col" />
|
||||||
|
<col class="first-value-col" />
|
||||||
|
<col class="label-col" />
|
||||||
|
<col class="value-col" />
|
||||||
|
<col class="label-col" />
|
||||||
|
<col class="value-col" />
|
||||||
|
<col class="label-col" />
|
||||||
|
<col class="value-col" />
|
||||||
|
<col class="label-col" />
|
||||||
|
<col class="value-col" />
|
||||||
|
<col class="label-col" />
|
||||||
|
<col class="value-col" />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="11" class="city-title" :style="cityTitleStyle">
|
||||||
【 {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} 】 {{ city.name }}
|
【 {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} 】 {{ city.name }}
|
||||||
</td>
|
</td>
|
||||||
<td class="city-title">{{ data.lastExecute }}</td>
|
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>주민</th>
|
<th>주민</th>
|
||||||
@@ -79,15 +182,9 @@ onMounted(() => void load());
|
|||||||
<th>민심</th>
|
<th>민심</th>
|
||||||
<td>{{ show(city.trust) }}</td>
|
<td>{{ show(city.trust) }}</td>
|
||||||
<th>시세</th>
|
<th>시세</th>
|
||||||
<td>{{ city.trade ?? '-' }}%</td>
|
<td>{{ city.trade ?? '- ' }}%</td>
|
||||||
<th>인구</th>
|
<th>인구</th>
|
||||||
<td>
|
<td>{{ populationRate }}%</td>
|
||||||
{{
|
|
||||||
city.population === null
|
|
||||||
? '?'
|
|
||||||
: ((city.population / city.populationMax) * 100).toFixed(2)
|
|
||||||
}}%
|
|
||||||
</td>
|
|
||||||
<th>태수</th>
|
<th>태수</th>
|
||||||
<td>{{ city.officers[4] }}</td>
|
<td>{{ city.officers[4] }}</td>
|
||||||
<th>군사</th>
|
<th>군사</th>
|
||||||
@@ -95,19 +192,60 @@ onMounted(() => void load());
|
|||||||
<th>종사</th>
|
<th>종사</th>
|
||||||
<td>{{ city.officers[2] }}</td>
|
<td>{{ city.officers[2] }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="summary">
|
||||||
|
<th>도시명</th>
|
||||||
|
<td>{{ city.name }}</td>
|
||||||
|
<th>적군</th>
|
||||||
|
<td>
|
||||||
|
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
|
||||||
|
show(summary.enemyGenerals)
|
||||||
|
}})
|
||||||
|
</td>
|
||||||
|
<th>병장(총)</th>
|
||||||
|
<td>
|
||||||
|
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
|
||||||
|
show(summary.ownGenerals)
|
||||||
|
}})
|
||||||
|
</td>
|
||||||
|
<th>90병장</th>
|
||||||
|
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
|
||||||
|
<th>60병장</th>
|
||||||
|
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
|
||||||
|
<th>수비○</th>
|
||||||
|
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>장수</th>
|
<th>장수</th>
|
||||||
<td colspan="11">
|
<td colspan="11" class="general-names">
|
||||||
{{
|
<template v-if="data.visibility.detailed">
|
||||||
data.visibility.detailed
|
<template v-if="data.generals.length">
|
||||||
? data.generals.map((g) => g.name).join(', ') || '-'
|
<template v-for="(general, index) in data.generals" :key="general.id">
|
||||||
: '알 수 없음'
|
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
|
||||||
}}
|
><template v-if="index < data.generals.length - 1">, </template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>-</template>
|
||||||
|
</template>
|
||||||
|
<span v-else class="unknown">알 수 없음</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
|
<table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 64px" />
|
||||||
|
<col style="width: 128px" />
|
||||||
|
<col style="width: 48px" />
|
||||||
|
<col style="width: 48px" />
|
||||||
|
<col style="width: 48px" />
|
||||||
|
<col style="width: 78px" />
|
||||||
|
<col style="width: 28px" />
|
||||||
|
<col style="width: 78px" />
|
||||||
|
<col style="width: 78px" />
|
||||||
|
<col style="width: 48px" />
|
||||||
|
<col style="width: 48px" />
|
||||||
|
<col style="width: 280px" />
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>얼 굴</th>
|
<th>얼 굴</th>
|
||||||
@@ -125,42 +263,53 @@ onMounted(() => void load());
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="general in data.generals" :key="general.id">
|
<tr
|
||||||
<td>
|
v-for="general in data.generals"
|
||||||
<img
|
:key="general.id"
|
||||||
v-if="general.picture"
|
:data-is-our-general="general.train !== null"
|
||||||
width="64"
|
:data-general-wounded="general.injury"
|
||||||
height="64"
|
>
|
||||||
:src="`/image/general/${general.picture}`"
|
<td class="icon-cell">
|
||||||
/>
|
<img class="general-icon" width="64" height="64" :src="generalImage(general)" />
|
||||||
|
</td>
|
||||||
|
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
|
||||||
|
<td :class="{ wounded: general.injury !== 0 }">
|
||||||
|
{{ woundedStat(general.leadership, general.injury)
|
||||||
|
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
|
||||||
|
>+{{ general.leadershipBonus }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td :class="{ wounded: general.injury !== 0 }">
|
||||||
|
{{ woundedStat(general.strength, general.injury) }}
|
||||||
|
</td>
|
||||||
|
<td :class="{ wounded: general.injury !== 0 }">
|
||||||
|
{{ woundedStat(general.intelligence, general.injury) }}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ general.name }}</td>
|
|
||||||
<td>{{ general.leadership }}</td>
|
|
||||||
<td>{{ general.strength }}</td>
|
|
||||||
<td>{{ general.intelligence }}</td>
|
|
||||||
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
|
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
|
||||||
<td>{{ general.defenceTrain ?? '?' }}</td>
|
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
|
||||||
<td>{{ general.crewTypeId ?? '?' }}</td>
|
<td>{{ general.crewTypeName ?? '?' }}</td>
|
||||||
<td>{{ general.crew ?? '?' }}</td>
|
<td>{{ general.crew ?? '?' }}</td>
|
||||||
<td>{{ general.train ?? '?' }}</td>
|
<td>{{ general.train ?? '?' }}</td>
|
||||||
<td>{{ general.atmos ?? '?' }}</td>
|
<td>{{ general.atmos ?? '?' }}</td>
|
||||||
<td class="turns">
|
<td class="turns">
|
||||||
{{
|
<template v-if="general.turns.length">
|
||||||
general.turns.length
|
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
|
||||||
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
|
>{{ index + 1 }} : {{ turn }}</span
|
||||||
: general.npcState > 1
|
>
|
||||||
? 'NPC 장수'
|
</template>
|
||||||
: `【${general.nationName}】 장수`
|
<template v-else-if="general.npcState > 1">NPC 장수</template>
|
||||||
}}
|
<template v-else-if="general.nationId !== data.me.nationId">
|
||||||
|
{{ general.nationId === 0 ? '재 야' : `【${general.nationName}】 장수` }}
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
<table class="legacy-table legacy-bg0 center footer">
|
<table class="legacy-table legacy-bg0 footer">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><RouterLink to="/">돌아가기</RouterLink></td>
|
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -170,60 +319,138 @@ onMounted(() => void load());
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.city-page {
|
.city-page {
|
||||||
width: 1000px;
|
width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 8px auto 0;
|
||||||
font-size: 14px;
|
font-family: 'Times New Roman', serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: normal;
|
||||||
}
|
}
|
||||||
.legacy-table {
|
.legacy-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
|
border-spacing: 2px;
|
||||||
}
|
}
|
||||||
.legacy-table td,
|
.legacy-table td,
|
||||||
.legacy-table th {
|
.legacy-table th {
|
||||||
border: 1px solid #777;
|
border: 0;
|
||||||
padding: 3px;
|
padding: 1px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.center {
|
.center,
|
||||||
|
.selector,
|
||||||
|
.stats td,
|
||||||
|
.stats th,
|
||||||
|
.generals th,
|
||||||
|
.generals td:not(:last-child) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.selector {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.selector select {
|
.selector select {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 400px;
|
min-width: 400px;
|
||||||
|
height: 19px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #767676;
|
||||||
|
background: #6b6b6b;
|
||||||
|
color: #fff;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 13.3333px;
|
||||||
|
}
|
||||||
|
.selector {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.selector p {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.back-row {
|
||||||
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
.stats {
|
.stats {
|
||||||
margin-top: 14px;
|
margin-top: 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.label-col {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
.value-col {
|
||||||
|
width: 108px;
|
||||||
|
}
|
||||||
|
.first-value-col {
|
||||||
|
width: 112px;
|
||||||
}
|
}
|
||||||
.stats th,
|
.stats th,
|
||||||
.generals th {
|
.generals th {
|
||||||
|
background-color: #14241b;
|
||||||
background-image: url('/image/game/back_green.jpg');
|
background-image: url('/image/game/back_green.jpg');
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.stats td {
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.city-title {
|
.city-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.generals {
|
.stats {
|
||||||
margin-top: 14px;
|
height: 136px;
|
||||||
}
|
}
|
||||||
.generals td {
|
.general-names {
|
||||||
text-align: center;
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
.unknown {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
.generals {
|
||||||
|
width: 1024px;
|
||||||
|
margin: 18px 0 0 50%;
|
||||||
|
table-layout: fixed;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
.generals td:last-child {
|
.generals td:last-child {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
}
|
}
|
||||||
|
.icon-cell {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.generals tbody tr {
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
.general-icon {
|
||||||
|
display: block;
|
||||||
|
width: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
.turns {
|
.turns {
|
||||||
font-size: x-small;
|
font-size: x-small;
|
||||||
}
|
}
|
||||||
|
.turn-line {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.wounded {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
.leadership-bonus {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.back-link:hover,
|
||||||
|
.back-link:focus,
|
||||||
|
.back-link:active {
|
||||||
|
border-color: #565e64;
|
||||||
|
background: #5c636a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.error {
|
.error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #ff7373;
|
color: #ff7373;
|
||||||
@@ -233,8 +460,5 @@ onMounted(() => void load());
|
|||||||
width: 1000px;
|
width: 1000px;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
.selector select {
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ onMounted(async () => {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div v-if="errorMessage" class="legacy-message error">{{ errorMessage }}</div>
|
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
|
||||||
<div v-else-if="loading" class="legacy-message">불러오는 중...</div>
|
<div v-else-if="loading" class="legacy-message">불러오는 중...</div>
|
||||||
<div v-else-if="!data" class="legacy-message">표시할 데이터가 없습니다.</div>
|
<div v-else-if="!data" class="legacy-message">표시할 데이터가 없습니다.</div>
|
||||||
|
|
||||||
@@ -171,6 +171,10 @@ onMounted(async () => {
|
|||||||
<div class="legacy-hall-bottom">
|
<div class="legacy-hall-bottom">
|
||||||
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="legacy-banner">
|
||||||
|
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
|
||||||
|
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -191,7 +195,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.legacy-hall-title,
|
.legacy-hall-title,
|
||||||
.legacy-hall-bottom {
|
.legacy-hall-bottom {
|
||||||
text-align: center;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legacy-hall-title {
|
.legacy-hall-title {
|
||||||
@@ -205,12 +209,33 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scenario-search select {
|
.scenario-search select {
|
||||||
min-width: 220px;
|
width: 189px;
|
||||||
|
height: 20px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
color: #303030;
|
color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legacy-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
background: #375a7f;
|
||||||
|
padding: 5.25px 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-button:hover,
|
||||||
|
.legacy-button:focus,
|
||||||
|
.legacy-button:active {
|
||||||
|
background: #6b6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-button:focus-visible {
|
||||||
|
outline: revert;
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.legacy-message {
|
.legacy-message {
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -221,6 +246,15 @@ onMounted(async () => {
|
|||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legacy-banner {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-banner a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.hall-sections {
|
.hall-sections {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -235,7 +269,9 @@ onMounted(async () => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
border-bottom: 1px solid gray;
|
border-bottom: 1px solid gray;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
font-size: 1.17em;
|
font-size: calc(19px + 0.784615vw);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +311,7 @@ onMounted(async () => {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
object-fit: cover;
|
object-fit: fill;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-server,
|
.hall-server,
|
||||||
@@ -309,5 +345,9 @@ onMounted(async () => {
|
|||||||
.legacy-hall-page {
|
.legacy-hall-page {
|
||||||
width: 1000px;
|
width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rankType {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
|
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
|
||||||
@@ -12,8 +10,8 @@ type BuffKey =
|
|||||||
| 'warAvoidRatio'
|
| 'warAvoidRatio'
|
||||||
| 'warCriticalRatio'
|
| 'warCriticalRatio'
|
||||||
| 'warMagicTrialProb'
|
| 'warMagicTrialProb'
|
||||||
| 'success'
|
| 'domesticSuccessProb'
|
||||||
| 'fail'
|
| 'domesticFailProb'
|
||||||
| 'warAvoidRatioOppose'
|
| 'warAvoidRatioOppose'
|
||||||
| 'warCriticalRatioOppose'
|
| 'warCriticalRatioOppose'
|
||||||
| 'warMagicTrialProbOppose';
|
| 'warMagicTrialProbOppose';
|
||||||
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
|
|||||||
'warAvoidRatio',
|
'warAvoidRatio',
|
||||||
'warCriticalRatio',
|
'warCriticalRatio',
|
||||||
'warMagicTrialProb',
|
'warMagicTrialProb',
|
||||||
'success',
|
'domesticSuccessProb',
|
||||||
'fail',
|
'domesticFailProb',
|
||||||
'warAvoidRatioOppose',
|
'warAvoidRatioOppose',
|
||||||
'warCriticalRatioOppose',
|
'warCriticalRatioOppose',
|
||||||
'warMagicTrialProbOppose',
|
'warMagicTrialProbOppose',
|
||||||
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
|
|||||||
warAvoidRatio: '회피 확률 증가',
|
warAvoidRatio: '회피 확률 증가',
|
||||||
warCriticalRatio: '필살 확률 증가',
|
warCriticalRatio: '필살 확률 증가',
|
||||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||||
success: '내정 성공률 증가',
|
domesticSuccessProb: '내정 성공 확률 증가',
|
||||||
fail: '내정 실패율 감소',
|
domesticFailProb: '내정 실패 확률 감소',
|
||||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||||
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
|
|||||||
const pointLabels: Record<string, string> = {
|
const pointLabels: Record<string, string> = {
|
||||||
previous: '보유',
|
previous: '보유',
|
||||||
lived_month: '생존 턴',
|
lived_month: '생존 턴',
|
||||||
max_domestic_critical: '내정 최고치',
|
max_domestic_critical: '최대 연속 내정 성공',
|
||||||
active_action: '활동',
|
active_action: '능동 행동 수',
|
||||||
combat: '전투',
|
combat: '전투 횟수',
|
||||||
sabotage: '계략',
|
sabotage: '계략 성공 횟수',
|
||||||
dex: '숙련',
|
dex: '숙련도',
|
||||||
unifier: '통일 보상',
|
unifier: '천통 기여',
|
||||||
tournament: '토너먼트',
|
tournament: '토너먼트',
|
||||||
betting: '베팅',
|
betting: '베팅 당첨',
|
||||||
max_belong: '최대 충성',
|
max_belong: '최대 임관년 수',
|
||||||
};
|
};
|
||||||
|
|
||||||
const pointOrder = [
|
const pointOrder = [
|
||||||
'previous',
|
'previous',
|
||||||
'lived_month',
|
'lived_month',
|
||||||
|
'max_belong',
|
||||||
'max_domestic_critical',
|
'max_domestic_critical',
|
||||||
'active_action',
|
'active_action',
|
||||||
'combat',
|
'combat',
|
||||||
@@ -65,9 +64,33 @@ const pointOrder = [
|
|||||||
'unifier',
|
'unifier',
|
||||||
'tournament',
|
'tournament',
|
||||||
'betting',
|
'betting',
|
||||||
'max_belong',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const pointHelp: Record<string, string> = {
|
||||||
|
previous: '이전에 물려받은 포인트입니다.',
|
||||||
|
lived_month: '살아남은 기간입니다. (1개월 단위)',
|
||||||
|
max_belong: '가장 오래 임관했던 국가의 연도입니다.',
|
||||||
|
max_domestic_critical: '성공한 내정 중 최대 연속값입니다.',
|
||||||
|
active_action: '장수 동향에 본인의 이름이 직접 나타난 수입니다. 일부 사령턴은 제외됩니다.',
|
||||||
|
combat: '전투 횟수입니다.',
|
||||||
|
sabotage: '계략 성공 횟수입니다.',
|
||||||
|
unifier: '천통에 기여한 포인트입니다. 각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.',
|
||||||
|
dex: '총 숙련도합입니다. 최대 숙련 이후에는 상승량이 1/3로 감소합니다.',
|
||||||
|
tournament: '토너먼트 입상 포인트입니다.',
|
||||||
|
betting: '성공적인 베팅을 했습니다. 수익율과 베팅 성공 횟수를 따릅니다.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffHelp: Record<BuffKey, string> = {
|
||||||
|
warAvoidRatio: '전투 시 회피 확률이 1%p ~ 5%p 증가합니다.',
|
||||||
|
warCriticalRatio: '전투 시 필살 확률이 1%p ~ 5%p 증가합니다.',
|
||||||
|
warMagicTrialProb: '전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.',
|
||||||
|
domesticSuccessProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 증가합니다.',
|
||||||
|
domesticFailProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 감소합니다.',
|
||||||
|
warAvoidRatioOppose: '전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.',
|
||||||
|
warCriticalRatioOppose: '전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.',
|
||||||
|
warMagicTrialProbOppose: '전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.',
|
||||||
|
};
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const status = ref<InheritStatus | null>(null);
|
const status = ref<InheritStatus | null>(null);
|
||||||
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
|
|||||||
warAvoidRatio: 1,
|
warAvoidRatio: 1,
|
||||||
warCriticalRatio: 1,
|
warCriticalRatio: 1,
|
||||||
warMagicTrialProb: 1,
|
warMagicTrialProb: 1,
|
||||||
success: 1,
|
domesticSuccessProb: 1,
|
||||||
fail: 1,
|
domesticFailProb: 1,
|
||||||
warAvoidRatioOppose: 1,
|
warAvoidRatioOppose: 1,
|
||||||
warCriticalRatioOppose: 1,
|
warCriticalRatioOppose: 1,
|
||||||
warMagicTrialProbOppose: 1,
|
warMagicTrialProbOppose: 1,
|
||||||
@@ -115,7 +138,7 @@ const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
|||||||
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
|
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
|
||||||
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
|
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
|
||||||
const resetStatCost = computed(() =>
|
const resetStatCost = computed(() =>
|
||||||
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
|
resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetStatErrors = computed(() => {
|
const resetStatErrors = computed(() => {
|
||||||
@@ -166,6 +189,9 @@ const pointEntries = computed(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const previousPoint = computed(() => status.value?.items.previous ?? 0);
|
||||||
|
const newPoint = computed(() => (status.value?.totalPoint ?? 0) - previousPoint.value);
|
||||||
|
|
||||||
const specialNameMap = computed(() => {
|
const specialNameMap = computed(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const entry of status.value?.availableSpecialWar ?? []) {
|
for (const entry of status.value?.availableSpecialWar ?? []) {
|
||||||
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentSpecialName = computed(() => {
|
|
||||||
if (!status.value) {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
|
|
||||||
});
|
|
||||||
|
|
||||||
const buffCost = (key: BuffKey, target: number): number => {
|
const buffCost = (key: BuffKey, target: number): number => {
|
||||||
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
|
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
|
||||||
const current = status.value?.buffLevels[key] ?? 0;
|
const current = status.value?.buffLevels[key] ?? 0;
|
||||||
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
|
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
|
||||||
};
|
};
|
||||||
|
|
||||||
const buffTargetOptions = (key: BuffKey): number[] => {
|
|
||||||
const current = status.value?.buffLevels[key] ?? 0;
|
|
||||||
const start = Math.min(5, Math.max(1, current + 1));
|
|
||||||
const result: number[] = [];
|
|
||||||
for (let level = start; level <= 5; level += 1) {
|
|
||||||
result.push(level);
|
|
||||||
}
|
|
||||||
return result.length > 0 ? result : [5];
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) {
|
||||||
return value.message;
|
return value.message;
|
||||||
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
|
|||||||
return 'unknown_error';
|
return 'unknown_error';
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyResetBalanced = () => {
|
|
||||||
const rules = statRules.value;
|
|
||||||
if (!rules) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const base = Math.floor(rules.total / 3);
|
|
||||||
resetStatForm.leadership = rules.total - base * 2;
|
|
||||||
resetStatForm.strength = base;
|
|
||||||
resetStatForm.intel = base;
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncSelections = () => {
|
const syncSelections = () => {
|
||||||
if (!status.value) {
|
if (!status.value) {
|
||||||
return;
|
return;
|
||||||
@@ -235,6 +233,14 @@ const syncSelections = () => {
|
|||||||
if (!uniqueForm.amount) {
|
if (!uniqueForm.amount) {
|
||||||
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
|
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
|
||||||
}
|
}
|
||||||
|
if (!uniqueForm.itemId) {
|
||||||
|
uniqueForm.itemId = status.value.availableUnique[0]?.key ?? '';
|
||||||
|
}
|
||||||
|
if (resetStatForm.leadership === 0 && resetStatForm.strength === 0 && resetStatForm.intel === 0) {
|
||||||
|
resetStatForm.leadership = status.value.currentStat.leadership;
|
||||||
|
resetStatForm.strength = status.value.currentStat.strength;
|
||||||
|
resetStatForm.intel = status.value.currentStat.intel;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadStatus = async () => {
|
const loadStatus = async () => {
|
||||||
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
|
|||||||
|
|
||||||
const openUniqueAuction = async () => {
|
const openUniqueAuction = async () => {
|
||||||
if (!uniqueForm.itemId.trim()) {
|
if (!uniqueForm.itemId.trim()) {
|
||||||
actionError.value = '유니크 아이템 ID를 입력해주세요.';
|
actionError.value = '유니크를 선택해주세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const amount = Math.max(0, Math.floor(uniqueForm.amount));
|
const amount = Math.max(0, Math.floor(uniqueForm.amount));
|
||||||
@@ -412,12 +418,6 @@ const checkOwner = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(statRules, (rules) => {
|
|
||||||
if (rules) {
|
|
||||||
applyResetBalanced();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadStatus();
|
void loadStatus();
|
||||||
void loadJoinConfig();
|
void loadJoinConfig();
|
||||||
@@ -426,464 +426,561 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="inherit-page">
|
<header class="top-back-bar legacy-bg0">
|
||||||
<header class="inherit-header">
|
<RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
|
||||||
<div>
|
<strong>유산 관리</strong>
|
||||||
<h1 class="inherit-title">유산 포인트</h1>
|
<button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
|
||||||
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
|
</header>
|
||||||
</div>
|
|
||||||
<div class="inherit-actions">
|
|
||||||
<button class="ghost" @click="loadStatus">새로고침</button>
|
|
||||||
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="error" class="inherit-error">{{ error }}</div>
|
<main id="container" class="inherit-page legacy-bg0">
|
||||||
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
|
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
|
||||||
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
|
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
|
||||||
|
<div v-if="loading" class="loading-state">불러오는 중...</div>
|
||||||
|
|
||||||
<div v-if="loading">
|
<template v-else-if="status">
|
||||||
<SkeletonLines :lines="4" />
|
<section id="inheritance_list" class="point-grid">
|
||||||
</div>
|
<article id="inherit_sum" class="inherit-item">
|
||||||
|
<label for="inherit_sum_value">총 포인트</label>
|
||||||
|
<input id="inherit_sum_value" :value="Math.floor(status.totalPoint).toLocaleString()" readonly />
|
||||||
|
<small>다음 플레이에서 사용할 수 있는 총 포인트입니다.</small>
|
||||||
|
</article>
|
||||||
|
<article id="inherit_previous" class="inherit-item">
|
||||||
|
<label for="inherit_previous_value">기존 포인트</label>
|
||||||
|
<input id="inherit_previous_value" :value="Math.floor(previousPoint).toLocaleString()" readonly />
|
||||||
|
<small>이전에 물려받은 포인트입니다.</small>
|
||||||
|
</article>
|
||||||
|
<article id="inherit_new" class="inherit-item">
|
||||||
|
<label for="inherit_new_value">신규 포인트</label>
|
||||||
|
<input id="inherit_new_value" :value="Math.floor(newPoint).toLocaleString()" readonly />
|
||||||
|
<small>이번 플레이에서 얻은 총 포인트입니다.</small>
|
||||||
|
</article>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<article
|
||||||
|
v-for="entry in pointEntries.filter((item) => item.key !== 'previous')"
|
||||||
|
:id="`inherit_${entry.key}`"
|
||||||
|
:key="entry.key"
|
||||||
|
class="inherit-item"
|
||||||
|
>
|
||||||
|
<label :for="`inherit_${entry.key}_value`">{{ entry.label }}</label>
|
||||||
|
<input
|
||||||
|
:id="`inherit_${entry.key}_value`"
|
||||||
|
:value="Math.floor(entry.value).toLocaleString()"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<small>{{ pointHelp[entry.key] }}</small>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-else class="inherit-grid">
|
<section id="inheritance_store">
|
||||||
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
|
<h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
|
||||||
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
|
|
||||||
<div v-else class="summary-panel">
|
|
||||||
<div class="summary-head">
|
|
||||||
<div class="summary-total">총 {{ status.totalPoint }} 포인트</div>
|
|
||||||
<div class="summary-state" :class="{ united: status.isUnited }">
|
|
||||||
{{ status.isUnited ? '통일 완료' : '진행 중' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-list">
|
|
||||||
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
|
|
||||||
<span>{{ entry.label }}</span>
|
|
||||||
<span>{{ entry.value }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-footer">
|
|
||||||
<div>현재 전투 특기: {{ currentSpecialName }}</div>
|
|
||||||
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}회</div>
|
|
||||||
<div>턴 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}회</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
|
<div class="action-grid leading-actions">
|
||||||
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
|
<article class="shop-item">
|
||||||
<div v-else class="buff-list">
|
<div class="control-row">
|
||||||
<div v-for="key in buffKeys" :key="key" class="buff-row">
|
<label for="next-special">다음 전투 특기 선택</label>
|
||||||
<div class="buff-info">
|
<select id="next-special" v-model="nextSpecialKey">
|
||||||
<div class="buff-name">{{ buffLabels[key] }}</div>
|
<option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
|
||||||
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
|
{{ entry.name }}
|
||||||
</div>
|
|
||||||
<div class="buff-action">
|
|
||||||
<select v-model.number="buffTargets[key]" class="form-input">
|
|
||||||
<option
|
|
||||||
v-for="level in buffTargetOptions(key)"
|
|
||||||
:key="level"
|
|
||||||
:value="level"
|
|
||||||
>
|
|
||||||
{{ level }} 단계
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div>
|
</div>
|
||||||
|
<small
|
||||||
|
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
|
||||||
|
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
|
||||||
|
></small
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="legacy-button buy-button"
|
||||||
|
:disabled="isUnited || actionBusy"
|
||||||
|
@click="reserveSpecialWar"
|
||||||
|
>
|
||||||
|
구입
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="shop-item">
|
||||||
|
<div class="control-row">
|
||||||
|
<label for="specific-unique">유니크 경매</label>
|
||||||
|
<select id="specific-unique" v-model="uniqueForm.itemId">
|
||||||
|
<option disabled value="">유니크 선택</option>
|
||||||
|
<option v-for="item in status.availableUnique" :key="item.key" :value="item.key">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<label for="specific-unique-amount">입찰 포인트</label>
|
||||||
|
<input
|
||||||
|
id="specific-unique-amount"
|
||||||
|
v-model.number="uniqueForm.amount"
|
||||||
|
type="number"
|
||||||
|
:min="status.inheritConst.inheritItemUniqueMinPoint"
|
||||||
|
:max="previousPoint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small
|
||||||
|
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24턴 동안 진행됩니다.<br />{{
|
||||||
|
status.availableUnique.find((item) => item.key === uniqueForm.itemId)?.info
|
||||||
|
}}</small
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="legacy-button buy-button"
|
||||||
|
:disabled="isUnited || actionBusy"
|
||||||
|
@click="openUniqueAuction"
|
||||||
|
>
|
||||||
|
경매 시작
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="action-grid">
|
||||||
|
<article class="shop-item simple-item">
|
||||||
|
<div class="control-row">
|
||||||
|
<span>랜덤 턴 초기화</span
|
||||||
|
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetTurnTime">
|
||||||
|
구입
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small
|
||||||
|
>다다음턴부터 시간이 랜덤하게 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||||
|
>필요 포인트: {{ status.resetCosts.resetTurnTime }}</b
|
||||||
|
><template v-if="turnTimeLabel"><br />적용 시간: {{ turnTimeLabel }}</template></small
|
||||||
|
>
|
||||||
|
</article>
|
||||||
|
<article class="shop-item simple-item">
|
||||||
|
<div class="control-row">
|
||||||
|
<span>랜덤 유니크 획득</span
|
||||||
|
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="buyRandomUnique">
|
||||||
|
구입
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small
|
||||||
|
>다음 턴에 랜덤 유니크를 얻습니다.<br /><b
|
||||||
|
>필요 포인트: {{ status.inheritConst.inheritItemRandomPoint }}</b
|
||||||
|
></small
|
||||||
|
>
|
||||||
|
</article>
|
||||||
|
<article class="shop-item simple-item">
|
||||||
|
<div class="control-row">
|
||||||
|
<span>즉시 전투 특기 초기화</span
|
||||||
|
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetSpecialWar">
|
||||||
|
구입
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small
|
||||||
|
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||||
|
>필요 포인트: {{ status.resetCosts.resetSpecialWar }}</b
|
||||||
|
></small
|
||||||
|
>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="buff-grid">
|
||||||
|
<article v-for="key in buffKeys" :key="key" class="shop-item buff-item">
|
||||||
|
<div class="control-row">
|
||||||
|
<label :for="`buff-${key}`">{{ buffLabels[key] }}</label>
|
||||||
|
<input
|
||||||
|
:id="`buff-${key}`"
|
||||||
|
v-model.number="buffTargets[key]"
|
||||||
|
type="number"
|
||||||
|
:min="status.buffLevels[key] ?? 0"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small
|
||||||
|
>{{ buffHelp[key] }}<br /><b>필요 포인트: {{ buffCost(key, buffTargets[key]) }}</b></small
|
||||||
|
>
|
||||||
|
<div class="dual-buttons">
|
||||||
<button
|
<button
|
||||||
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5"
|
class="legacy-button secondary"
|
||||||
|
:disabled="actionBusy"
|
||||||
|
@click="buffTargets[key] = status.buffLevels[key] ?? 0"
|
||||||
|
>
|
||||||
|
리셋
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="legacy-button"
|
||||||
|
:disabled="isUnited || actionBusy"
|
||||||
@click="buyHiddenBuff(key)"
|
@click="buyHiddenBuff(key)"
|
||||||
>
|
>
|
||||||
구입
|
구입
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
|
<div class="divider"></div>
|
||||||
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
|
|
||||||
<div v-else class="action-stack">
|
<div class="action-grid bottom-actions">
|
||||||
<div class="action-row">
|
<article class="shop-item">
|
||||||
<label class="form-field">
|
<div class="control-row">
|
||||||
<span>다음 전투 특기</span>
|
<label for="owner-target">장수 소유자 확인</label>
|
||||||
<select v-model="nextSpecialKey" class="form-input">
|
<select id="owner-target" v-model="ownerTargetId">
|
||||||
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
|
<option disabled value="">장수 선택</option>
|
||||||
{{ special.name }}
|
<option
|
||||||
|
v-for="general in status.availableTargetGenerals"
|
||||||
|
:key="general.id"
|
||||||
|
:value="String(general.id)"
|
||||||
|
>
|
||||||
|
{{ general.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
|
</div>
|
||||||
</label>
|
<small
|
||||||
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
|
>장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
|
||||||
</div>
|
>필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
|
||||||
<div class="action-row">
|
></small
|
||||||
<div>
|
>
|
||||||
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
|
<button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
|
||||||
<div class="muted">
|
소유자 찾기
|
||||||
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
|
</button>
|
||||||
({{ status.resetLevels.resetSpecialWar }}회)
|
<p v-if="ownerResult" class="owner-result">
|
||||||
|
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="shop-item stat-reset">
|
||||||
|
<div class="stat-layout">
|
||||||
|
<span>능력치 초기화</span>
|
||||||
|
<div>
|
||||||
|
<strong>기본 능력치</strong>
|
||||||
|
<label
|
||||||
|
>통
|
||||||
|
<input
|
||||||
|
v-model.number="resetStatForm.leadership"
|
||||||
|
type="number"
|
||||||
|
:min="statRules?.min"
|
||||||
|
:max="statRules?.max"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>무
|
||||||
|
<input
|
||||||
|
v-model.number="resetStatForm.strength"
|
||||||
|
type="number"
|
||||||
|
:min="statRules?.min"
|
||||||
|
:max="statRules?.max"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>지
|
||||||
|
<input
|
||||||
|
v-model.number="resetStatForm.intel"
|
||||||
|
type="number"
|
||||||
|
:min="statRules?.min"
|
||||||
|
:max="statRules?.max"
|
||||||
|
/></label>
|
||||||
|
<strong>추가 능력치</strong>
|
||||||
|
<label
|
||||||
|
>통 <input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>무 <input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>지 <input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5"
|
||||||
|
/></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
|
<small
|
||||||
</div>
|
>시즌 당 1회에 한 해 능력치를 초기화합니다.<br /><b>필요 포인트: {{ resetStatCost }}</b
|
||||||
|
><br /><span v-if="resetStatErrors.length">{{ resetStatErrors[0] }}</span></small
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="legacy-button buy-button"
|
||||||
|
:disabled="isUnited || actionBusy || resetStatErrors.length > 0"
|
||||||
|
@click="resetStats"
|
||||||
|
>
|
||||||
|
능력치 초기화
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</section>
|
||||||
|
|
||||||
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
|
<section class="inherit-logs">
|
||||||
<div v-if="!status" class="muted">턴 시간 정보를 불러오지 못했습니다.</div>
|
<h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
|
||||||
<div v-else class="action-stack">
|
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
||||||
<div class="action-row">
|
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
|
||||||
<div class="muted">
|
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
|
||||||
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }}회)
|
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
|
||||||
</div>
|
<span>{{ entry.text }}</span>
|
||||||
<button :disabled="isUnited || actionBusy" @click="resetTurnTime">턴 시간 변경</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
<button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
|
||||||
|
더 가져오기
|
||||||
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
|
</button>
|
||||||
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
|
</section>
|
||||||
<div v-else class="stat-panel">
|
</template>
|
||||||
<div class="stat-grid">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>통솔</span>
|
|
||||||
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>무력</span>
|
|
||||||
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>지력</span>
|
|
||||||
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-grid">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>보너스 통솔</span>
|
|
||||||
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>보너스 무력</span>
|
|
||||||
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>보너스 지력</span>
|
|
||||||
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-summary">
|
|
||||||
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
|
|
||||||
<div>보너스 합 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
|
|
||||||
<div v-if="resetStatErrors.length" class="stat-errors">
|
|
||||||
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-row">
|
|
||||||
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
|
|
||||||
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
|
|
||||||
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
|
|
||||||
<div v-else class="action-stack">
|
|
||||||
<div class="action-row">
|
|
||||||
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
|
|
||||||
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
|
|
||||||
</div>
|
|
||||||
<div class="action-row">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>유니크 아이템 ID</span>
|
|
||||||
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>입찰 포인트</span>
|
|
||||||
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
|
|
||||||
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
|
|
||||||
</label>
|
|
||||||
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
|
|
||||||
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
|
|
||||||
<div v-else class="action-stack">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>대상 장수</span>
|
|
||||||
<select v-model="ownerTargetId" class="form-input">
|
|
||||||
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
|
|
||||||
{{ general.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
|
|
||||||
</label>
|
|
||||||
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
|
|
||||||
<div v-if="ownerResult" class="muted">
|
|
||||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
|
|
||||||
<template #actions>
|
|
||||||
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
|
|
||||||
</template>
|
|
||||||
<div v-if="logLoading && logs.length === 0">
|
|
||||||
<SkeletonLines :lines="3" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
|
|
||||||
<div v-else class="log-list">
|
|
||||||
<div v-for="entry in logs" :key="entry.id" class="log-entry">
|
|
||||||
<span class="log-date">{{ entry.year }}년 {{ entry.month }}월</span>
|
|
||||||
<span>{{ entry.text }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="log-footer">
|
|
||||||
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()">더 보기</button>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.inherit-page {
|
.top-back-bar {
|
||||||
min-height: 100vh;
|
width: min(100%, 1000px);
|
||||||
padding: 24px;
|
min-height: 38px;
|
||||||
display: flex;
|
margin: 0 auto;
|
||||||
flex-direction: column;
|
border: 1px solid #888;
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inherit-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inherit-title {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inherit-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inherit-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inherit-error {
|
|
||||||
border: 1px solid rgba(240, 90, 90, 0.6);
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: rgba(240, 150, 150, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inherit-message {
|
|
||||||
border: 1px solid rgba(120, 190, 120, 0.5);
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: rgba(180, 230, 180, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inherit-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
grid-template-columns: 100px 1fr 100px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
text-align: center;
|
||||||
gap: 8px;
|
padding: 3px 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-total {
|
.top-button {
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-state {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-state.united {
|
.inherit-page {
|
||||||
border-color: rgba(240, 120, 120, 0.6);
|
width: min(100%, 1000px);
|
||||||
color: rgba(240, 150, 150, 0.9);
|
margin: 0 auto;
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-top: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 8px 10px;
|
||||||
|
color: #fff;
|
||||||
|
font:
|
||||||
|
14px/1.3 Pretendard,
|
||||||
|
'Apple SD Gothic Neo',
|
||||||
|
'Noto Sans KR',
|
||||||
|
'Malgun Gothic',
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-list {
|
.notice,
|
||||||
display: flex;
|
.loading-state,
|
||||||
flex-direction: column;
|
.log-empty {
|
||||||
gap: 4px;
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-row {
|
.notice.error {
|
||||||
display: flex;
|
color: #ffb0b0;
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(232, 221, 196, 0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-footer {
|
.notice.success {
|
||||||
display: flex;
|
color: #b6efb6;
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.buff-list {
|
.point-grid,
|
||||||
display: flex;
|
.action-grid,
|
||||||
flex-direction: column;
|
.buff-grid {
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff-level {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff-action {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff-cost {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-stack {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-summary {
|
.inherit-item,
|
||||||
font-size: 0.75rem;
|
.shop-item {
|
||||||
color: rgba(232, 221, 196, 0.7);
|
padding: 8px 16px;
|
||||||
display: flex;
|
box-sizing: border-box;
|
||||||
flex-direction: column;
|
min-width: 0;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-errors {
|
.inherit-item {
|
||||||
color: rgba(240, 150, 150, 0.9);
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field {
|
.inherit-item label {
|
||||||
|
text-align: right;
|
||||||
|
padding: 7px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit-item input,
|
||||||
|
.shop-item input,
|
||||||
|
.shop-item select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #212529;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit-item small,
|
||||||
|
.shop-item small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 34px;
|
||||||
|
text-align: right;
|
||||||
|
color: #aeb2b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit-item small {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
margin: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leading-actions .shop-item:first-child {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row > label,
|
||||||
|
.control-row > span {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.shop-item .buy-button {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
width: 50%;
|
||||||
background: rgba(10, 10, 10, 0.8);
|
margin-left: auto;
|
||||||
padding: 6px 8px;
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.simple-item small {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
min-height: 55px;
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.ghost {
|
.buff-item small {
|
||||||
background: transparent;
|
min-height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-list {
|
.dual-buttons {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-button.secondary {
|
||||||
|
border-color: #51585e;
|
||||||
|
background: #5c636a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-actions .shop-item:first-child {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry {
|
.stat-layout > div {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
gap: 3px;
|
||||||
gap: 2px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-date {
|
.stat-layout label {
|
||||||
font-size: 0.7rem;
|
display: grid;
|
||||||
color: rgba(232, 221, 196, 0.6);
|
grid-template-columns: 22px 1fr;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-footer {
|
.stat-layout strong {
|
||||||
margin-top: 8px;
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.owner-result {
|
||||||
color: rgba(232, 221, 196, 0.6);
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
color: #fff;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit-logs {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(150px, 20ch) 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row small {
|
||||||
|
color: #aeb2b6;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid #f39c12;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.point-grid,
|
||||||
|
.action-grid,
|
||||||
|
.buff-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.leading-actions .shop-item:first-child {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.top-back-bar,
|
||||||
|
.inherit-page {
|
||||||
|
width: 500px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-grid,
|
||||||
|
.action-grid,
|
||||||
|
.buff-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit-item,
|
||||||
|
.shop-item {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row small {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { RouterLink, useRouter } from 'vue-router';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -281,6 +281,7 @@ onMounted(() => {
|
|||||||
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="join-tabs">
|
<div class="join-tabs">
|
||||||
|
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
||||||
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,17 +465,13 @@ onMounted(() => {
|
|||||||
<section v-else class="join-grid">
|
<section v-else class="join-grid">
|
||||||
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
|
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
|
||||||
목록 새로고침
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
||||||
<div v-if="npcLoading && npcCandidates.length === 0">
|
<div v-if="npcLoading && npcCandidates.length === 0">
|
||||||
<SkeletonLines :lines="3" />
|
<SkeletonLines :lines="3" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="npcCandidates.length === 0" class="muted">
|
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
|
||||||
빙의 가능한 NPC가 없습니다.
|
|
||||||
</div>
|
|
||||||
<div v-else class="npc-list">
|
<div v-else class="npc-list">
|
||||||
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
||||||
<div class="npc-header">
|
<div class="npc-header">
|
||||||
@@ -490,9 +487,7 @@ onMounted(() => {
|
|||||||
<div>나이 {{ npc.age }}</div>
|
<div>나이 {{ npc.age }}</div>
|
||||||
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
|
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
|
||||||
빙의
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="npc-footer">
|
<div class="npc-footer">
|
||||||
@@ -542,6 +537,14 @@ onMounted(() => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.simulator-link {
|
||||||
|
border: 1px solid rgba(112, 170, 141, 0.55);
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #bfe2cd;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.join-tabs button.active {
|
.join-tabs button.active {
|
||||||
background: rgba(201, 164, 90, 0.2);
|
background: rgba(201, 164, 90, 0.2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const {
|
|||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxOptions,
|
mailboxGroups,
|
||||||
statusLine,
|
statusLine,
|
||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
} = storeToRefs(dashboard);
|
} = storeToRefs(dashboard);
|
||||||
@@ -104,6 +104,10 @@ watch(
|
|||||||
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
|
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
|
||||||
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
|
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||||
|
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="ghost" to="/nation/secret"
|
||||||
|
>암행부</RouterLink
|
||||||
|
>
|
||||||
|
<span v-else class="ghost disabled" aria-disabled="true">암행부</span>
|
||||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||||
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
|
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
|
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
|
||||||
@@ -115,10 +119,11 @@ watch(
|
|||||||
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
|
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
|
||||||
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
|
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
|
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
|
||||||
|
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
|
||||||
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
||||||
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
||||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
<RouterLink class="ghost" to="/my-page">내 정보&설정</RouterLink>
|
||||||
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
|
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
|
||||||
>토너먼트</RouterLink
|
>토너먼트</RouterLink
|
||||||
>
|
>
|
||||||
@@ -216,22 +221,26 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
||||||
<PanelCard title="메시지함">
|
<MessagePanel
|
||||||
<MessagePanel
|
class="mobile-message-panel"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:target-mailbox="targetMailbox"
|
:target-mailbox="targetMailbox"
|
||||||
:draft-text="messageDraftText"
|
:draft-text="messageDraftText"
|
||||||
:mailbox-options="mailboxOptions"
|
:mailbox-groups="mailboxGroups"
|
||||||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
:general-id="general?.id ?? 0"
|
||||||
@update:target-mailbox="targetMailbox = $event"
|
:general-name="general?.name ?? ''"
|
||||||
@update:draft-text="messageDraftText = $event"
|
:nation-id="general?.nationId ?? 0"
|
||||||
@send="dashboard.sendMessage"
|
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||||
@load-older="dashboard.loadOlderMessages"
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
@refresh="dashboard.refreshMessages"
|
@update:draft-text="messageDraftText = $event"
|
||||||
@respond="dashboard.respondToMessage"
|
@send="dashboard.sendMessage"
|
||||||
/>
|
@load-older="dashboard.loadOlderMessages"
|
||||||
</PanelCard>
|
@refresh="dashboard.refreshMessages"
|
||||||
|
@respond="dashboard.respondToMessage"
|
||||||
|
@read-latest="dashboard.readLatestMessage"
|
||||||
|
@delete="dashboard.deleteMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -251,22 +260,6 @@ watch(
|
|||||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="메시지함">
|
|
||||||
<MessagePanel
|
|
||||||
:messages="messages"
|
|
||||||
:loading="loading"
|
|
||||||
:target-mailbox="targetMailbox"
|
|
||||||
:draft-text="messageDraftText"
|
|
||||||
:mailbox-options="mailboxOptions"
|
|
||||||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
|
||||||
@update:target-mailbox="targetMailbox = $event"
|
|
||||||
@update:draft-text="messageDraftText = $event"
|
|
||||||
@send="dashboard.sendMessage"
|
|
||||||
@load-older="dashboard.loadOlderMessages"
|
|
||||||
@refresh="dashboard.refreshMessages"
|
|
||||||
@respond="dashboard.respondToMessage"
|
|
||||||
/>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
@@ -302,6 +295,26 @@ watch(
|
|||||||
<div v-else class="placeholder">개인 기록 영역</div>
|
<div v-else class="placeholder">개인 기록 영역</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
<MessagePanel
|
||||||
|
class="desktop-message-panel"
|
||||||
|
:messages="messages"
|
||||||
|
:loading="loading"
|
||||||
|
:target-mailbox="targetMailbox"
|
||||||
|
:draft-text="messageDraftText"
|
||||||
|
:mailbox-groups="mailboxGroups"
|
||||||
|
:general-id="general?.id ?? 0"
|
||||||
|
:general-name="general?.name ?? ''"
|
||||||
|
:nation-id="general?.nationId ?? 0"
|
||||||
|
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||||
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
|
@update:draft-text="messageDraftText = $event"
|
||||||
|
@send="dashboard.sendMessage"
|
||||||
|
@load-older="dashboard.loadOlderMessages"
|
||||||
|
@refresh="dashboard.refreshMessages"
|
||||||
|
@respond="dashboard.respondToMessage"
|
||||||
|
@read-latest="dashboard.readLatestMessage"
|
||||||
|
@delete="dashboard.deleteMessage"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -396,6 +409,16 @@ button {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-message-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-message-panel {
|
||||||
|
width: 100vw;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: -24px;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-mobile {
|
.layout-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
||||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
|
||||||
import CityBasicCard from '../components/main/CityBasicCard.vue';
|
|
||||||
import NationBasicCard from '../components/main/NationBasicCard.vue';
|
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
|
|
||||||
const SCREEN_MODE_KEY = 'sammo-screen-mode';
|
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||||
|
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||||
|
type ScreenMode = 'auto' | '500px' | '1000px';
|
||||||
|
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
||||||
|
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
||||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
|
|
||||||
type WorldStateSnapshot = {
|
type WorldSnapshot = {
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
currentMonth: number;
|
currentMonth: number;
|
||||||
tickSeconds: number;
|
tickSeconds: number;
|
||||||
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
|
|||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
type SettingForm = {
|
||||||
|
tnmt: number;
|
||||||
type LogLine = {
|
defence_train: number;
|
||||||
id: number;
|
use_treatment: number;
|
||||||
html: string;
|
use_auto_nation_turn: number;
|
||||||
};
|
|
||||||
|
|
||||||
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
|
||||||
|
|
||||||
type ItemSlot = {
|
|
||||||
key: ItemSlotKey;
|
|
||||||
label: string;
|
|
||||||
code: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
|
|
||||||
const logLabels: Record<LogType, string> = {
|
|
||||||
generalHistory: '장수 열전',
|
|
||||||
battleDetail: '전투 기록',
|
|
||||||
battleResult: '전투 결과',
|
|
||||||
generalAction: '개인 기록',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const data = ref<MyGeneralResponse | null>(null);
|
||||||
|
const world = ref<WorldSnapshot>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const data = ref<MyGeneralResponse | null>(null);
|
const screenMode = ref<ScreenMode>('auto');
|
||||||
const worldState = ref<WorldStateSnapshot>(null);
|
const customCss = ref('');
|
||||||
|
const cssSaving = ref(false);
|
||||||
|
let cssTimer: number | null = null;
|
||||||
|
|
||||||
const logs = reactive<Record<LogType, LogLine[]>>({
|
const form = reactive<SettingForm>({
|
||||||
|
tnmt: 1,
|
||||||
|
defence_train: 80,
|
||||||
|
use_treatment: 10,
|
||||||
|
use_auto_nation_turn: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
|
||||||
|
const logLabels: Record<LogType, string> = {
|
||||||
|
generalAction: '개인 기록',
|
||||||
|
battleDetail: '전투 기록',
|
||||||
|
generalHistory: '장수 열전',
|
||||||
|
battleResult: '전투 결과',
|
||||||
|
};
|
||||||
|
const logColors: Record<LogType, string> = {
|
||||||
|
generalAction: 'skyblue',
|
||||||
|
battleDetail: 'orange',
|
||||||
|
generalHistory: 'skyblue',
|
||||||
|
battleResult: 'orange',
|
||||||
|
};
|
||||||
|
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
|
||||||
generalHistory: [],
|
generalHistory: [],
|
||||||
battleDetail: [],
|
battleDetail: [],
|
||||||
battleResult: [],
|
battleResult: [],
|
||||||
generalAction: [],
|
generalAction: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const logLoading = reactive<Record<LogType, boolean>>({
|
const logLoading = reactive<Record<LogType, boolean>>({
|
||||||
generalHistory: false,
|
generalHistory: false,
|
||||||
battleDetail: false,
|
battleDetail: false,
|
||||||
battleResult: false,
|
battleResult: false,
|
||||||
generalAction: false,
|
generalAction: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logHasMore = reactive<Record<LogType, boolean>>({
|
const logHasMore = reactive<Record<LogType, boolean>>({
|
||||||
generalHistory: true,
|
generalHistory: true,
|
||||||
battleDetail: true,
|
battleDetail: true,
|
||||||
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
|
|||||||
generalAction: true,
|
generalAction: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeLogTab = ref<LogType>('generalAction');
|
const errorText = (value: unknown): string =>
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
|
||||||
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
|
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||||
if (value instanceof Error) {
|
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
return value.message;
|
|
||||||
}
|
const numberValue = (value: unknown, fallback: number): number => {
|
||||||
if (typeof value === 'string') {
|
const parsed = typeof value === 'number' ? value : Number(value);
|
||||||
return value;
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
}
|
|
||||||
return 'unknown_error';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveNumber = (value: unknown, fallback: number): number => {
|
const statusLine = computed(() =>
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
world.value
|
||||||
return value;
|
? `${world.value.currentYear}년 ${world.value.currentMonth}월 · ${Math.max(
|
||||||
}
|
1,
|
||||||
if (typeof value === 'string') {
|
Math.round(world.value.tickSeconds / 60)
|
||||||
const parsed = Number(value);
|
)}분 턴`
|
||||||
if (Number.isFinite(parsed)) {
|
: '내 정보를 불러오는 중'
|
||||||
return parsed;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLine = computed(() => {
|
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
|
||||||
if (!worldState.value) {
|
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
|
||||||
return '내 정보를 불러오는 중';
|
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
|
||||||
}
|
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
|
||||||
|
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
|
||||||
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
|
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
|
||||||
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}분` : '';
|
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
|
||||||
return `${worldState.value.currentYear}년 ${worldState.value.currentMonth}월${termLabel}`;
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
const itemSlots = computed<ItemSlot[]>(() => {
|
|
||||||
const items = data.value?.general?.items;
|
|
||||||
return [
|
|
||||||
{ key: 'horse', label: '말', code: items?.horse ?? null },
|
|
||||||
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
|
|
||||||
{ key: 'book', label: '서적', code: items?.book ?? null },
|
|
||||||
{ key: 'item', label: '아이템', code: items?.item ?? null },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||||
|
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
|
||||||
|
const showVacation = computed(() => !autorunUser.value.limit_minutes);
|
||||||
const actionAvailability = computed(() => {
|
const actionAvailability = computed(() => {
|
||||||
const general = data.value?.general;
|
const general = data.value?.general;
|
||||||
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
|
const meta = world.value?.meta ?? {};
|
||||||
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
|
const config = world.value?.config ?? {};
|
||||||
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
const constConfig = asRecord(config.const);
|
||||||
|
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
|
||||||
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
|
const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
|
||||||
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
|
const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
|
||||||
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
|
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
|
||||||
|
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
|
||||||
const npcMode = resolveNumber(config.npcMode, 0);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
|
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
|
||||||
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
|
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
|
||||||
canVacation: !(autorunUser.limit_minutes ?? false),
|
instantRetreat: Boolean(availableInstantAction.instantRetreat),
|
||||||
canInstantRetreat: Boolean(general && general.nationId > 0),
|
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
|
||||||
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const applyCustomCss = (text: string) => {
|
||||||
if (!data.value?.general?.id) {
|
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
|
||||||
return;
|
if (!style) {
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'sammo-custom-css';
|
||||||
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
|
style.textContent = text;
|
||||||
await Promise.all(
|
|
||||||
logTypes.map((type) => loadLog(type))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLog = async (type: LogType, beforeId?: number) => {
|
const loadLog = async (type: LogType, beforeId?: number) => {
|
||||||
if (logLoading[type]) {
|
if (logLoading[type]) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logLoading[type] = true;
|
logLoading[type] = true;
|
||||||
try {
|
try {
|
||||||
const response = await trpc.general.getMyLog.query({ type, beforeId });
|
const response = await trpc.general.getMyLog.query({ type, beforeId });
|
||||||
const formatted = response.logs.map((entry) => ({
|
const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
|
||||||
id: entry.id,
|
logs[type] = beforeId ? [...logs[type], ...next] : next;
|
||||||
html: formatLog(entry.text),
|
logHasMore[type] = next.length >= 24;
|
||||||
}));
|
} catch (cause) {
|
||||||
|
error.value = errorText(cause);
|
||||||
if (beforeId) {
|
|
||||||
logs[type].push(...formatted);
|
|
||||||
} else {
|
|
||||||
logs[type] = formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
logHasMore[type] = formatted.length >= 24;
|
|
||||||
} catch (err) {
|
|
||||||
error.value = resolveErrorMessage(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
logLoading[type] = false;
|
logLoading[type] = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMyPage = async () => {
|
const loadPage = async () => {
|
||||||
if (loading.value) {
|
if (loading.value) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const general = await trpc.general.me.query();
|
const [general, state] = await Promise.all([
|
||||||
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
|
trpc.general.me.query(),
|
||||||
|
trpc.world.getState.query() as Promise<WorldSnapshot>,
|
||||||
|
]);
|
||||||
data.value = general;
|
data.value = general;
|
||||||
worldState.value = world ?? null;
|
world.value = state;
|
||||||
await loadLogs();
|
if (general) {
|
||||||
} catch (err) {
|
Object.assign(form, general.settings);
|
||||||
error.value = resolveErrorMessage(err);
|
}
|
||||||
|
await Promise.all(logTypes.map((type) => loadLog(type)));
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = errorText(cause);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmAction = async (message: string, action: () => Promise<void>) => {
|
const saveSettings = async () => {
|
||||||
if (!confirm(message)) {
|
if (!canSave.value) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await action();
|
await trpc.general.setMySetting.mutate({ ...form });
|
||||||
await loadMyPage();
|
await loadPage();
|
||||||
} catch (err) {
|
} catch (cause) {
|
||||||
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
|
alert(`실패했습니다: ${errorText(cause)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDieOnPrestart = () =>
|
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
|
||||||
confirmAction('정말로 삭제하시겠습니까?', async () => {
|
if (!confirm(message)) return;
|
||||||
await trpc.general.dieOnPrestart.mutate();
|
try {
|
||||||
window.location.reload();
|
await mutation();
|
||||||
});
|
await loadPage();
|
||||||
|
} catch (cause) {
|
||||||
const handleBuildNationCandidate = () =>
|
alert(`실패했습니다: ${errorText(cause)}`);
|
||||||
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
|
|
||||||
await trpc.general.buildNationCandidate.mutate();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleInstantRetreat = () =>
|
|
||||||
confirmAction('아군 접경으로 이동할까요?', async () => {
|
|
||||||
await trpc.general.instantRetreat.mutate();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleVacation = () =>
|
|
||||||
confirmAction('휴가 기능을 신청할까요?', async () => {
|
|
||||||
await trpc.general.vacation.mutate();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDropItem = (slot: ItemSlot) =>
|
|
||||||
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
|
|
||||||
await trpc.general.dropItem.mutate({ itemType: slot.key });
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshScreenMode = () => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
|
|
||||||
if (mode === '500px' || mode === '1000px') {
|
|
||||||
screenMode.value = mode;
|
|
||||||
} else {
|
|
||||||
screenMode.value = 'auto';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
|
||||||
() => isMobile.value,
|
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
|
||||||
(value) => {
|
trpc.general.dropItem.mutate({ itemType: item.key })
|
||||||
if (value && !logTypes.includes(activeLogTab.value)) {
|
);
|
||||||
activeLogTab.value = 'generalAction';
|
|
||||||
}
|
watch(screenMode, (mode) => {
|
||||||
}
|
localStorage.setItem(SCREEN_MODE_KEY, mode);
|
||||||
);
|
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(customCss, (text) => {
|
||||||
|
if (cssTimer !== null) window.clearTimeout(cssTimer);
|
||||||
|
cssSaving.value = true;
|
||||||
|
cssTimer = window.setTimeout(() => {
|
||||||
|
localStorage.setItem(CUSTOM_CSS_KEY, text);
|
||||||
|
applyCustomCss(text);
|
||||||
|
cssSaving.value = false;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshScreenMode();
|
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
|
||||||
void loadMyPage();
|
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
|
||||||
|
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
||||||
|
applyCustomCss(customCss.value);
|
||||||
|
void loadPage();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="my-page" :class="`screen-${screenMode}`">
|
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
|
||||||
<header class="page-header">
|
<div class="title-row">
|
||||||
<div>
|
<span>내 정 보</span>
|
||||||
<h1 class="page-title">내 정보</h1>
|
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
|
||||||
<p class="page-subtitle">{{ statusLine }}</p>
|
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
|
||||||
<RouterLink class="ghost" to="/">메인</RouterLink>
|
|
||||||
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
|
|
||||||
<button class="ghost" @click="loadMyPage">새로고침</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error-row">{{ error }}</div>
|
||||||
|
<div class="status-row">{{ statusLine }}</div>
|
||||||
|
|
||||||
<section class="layout-grid">
|
<section class="top-grid">
|
||||||
<div class="stack">
|
<div class="general-column">
|
||||||
<PanelCard title="장수 상태">
|
<div class="section-title sky">장수 정보</div>
|
||||||
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
|
<div v-if="loading || !data" class="loading">불러오는 중...</div>
|
||||||
</PanelCard>
|
<div v-else class="general-table">
|
||||||
<PanelCard title="도시 상태">
|
<div class="portrait-cell">
|
||||||
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
|
<img
|
||||||
</PanelCard>
|
:src="
|
||||||
<PanelCard title="세력 상태">
|
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
|
||||||
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
|
"
|
||||||
</PanelCard>
|
alt=""
|
||||||
</div>
|
/>
|
||||||
|
<strong>{{ data.general.name }}</strong>
|
||||||
<div class="stack">
|
|
||||||
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
|
|
||||||
<div class="action-grid">
|
|
||||||
<button
|
|
||||||
v-if="actionAvailability.canVacation"
|
|
||||||
class="action-btn"
|
|
||||||
type="button"
|
|
||||||
@click="handleVacation"
|
|
||||||
>
|
|
||||||
휴가 신청
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="actionAvailability.canDieOnPrestart"
|
|
||||||
class="action-btn"
|
|
||||||
type="button"
|
|
||||||
@click="handleDieOnPrestart"
|
|
||||||
>
|
|
||||||
장수 삭제
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="actionAvailability.canBuildNationCandidate"
|
|
||||||
class="action-btn"
|
|
||||||
type="button"
|
|
||||||
@click="handleBuildNationCandidate"
|
|
||||||
>
|
|
||||||
사전 거병
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="actionAvailability.canInstantRetreat"
|
|
||||||
class="action-btn"
|
|
||||||
type="button"
|
|
||||||
@click="handleInstantRetreat"
|
|
||||||
>
|
|
||||||
접경 귀환
|
|
||||||
</button>
|
|
||||||
<RouterLink
|
|
||||||
v-if="actionAvailability.canSelectOtherGeneral"
|
|
||||||
class="action-btn link"
|
|
||||||
to="/join"
|
|
||||||
>
|
|
||||||
다른 장수 선택
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
<dl>
|
||||||
|
<div>
|
||||||
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
|
<dt>통솔</dt>
|
||||||
<div class="item-grid">
|
<dd>{{ data.general.stats.leadership }}</dd>
|
||||||
<button
|
|
||||||
v-for="slot in itemSlots"
|
|
||||||
:key="slot.key"
|
|
||||||
class="item-btn"
|
|
||||||
type="button"
|
|
||||||
:disabled="!slot.code"
|
|
||||||
@click="handleDropItem(slot)"
|
|
||||||
>
|
|
||||||
{{ slot.label }}: {{ slot.code ?? '-' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard v-if="isMobile" title="장수 기록">
|
|
||||||
<div class="log-tabs">
|
|
||||||
<button
|
|
||||||
v-for="type in logTypes"
|
|
||||||
:key="type"
|
|
||||||
:class="{ active: activeLogTab === type }"
|
|
||||||
@click="activeLogTab = type"
|
|
||||||
>
|
|
||||||
{{ logLabels[type] }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="log-block">
|
|
||||||
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
|
|
||||||
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
|
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
|
|
||||||
<div
|
|
||||||
v-for="entry in logs[activeLogTab]"
|
|
||||||
:key="entry.id"
|
|
||||||
class="log-line"
|
|
||||||
v-html="entry.html"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="logHasMore[activeLogTab]"
|
|
||||||
class="ghost log-more"
|
|
||||||
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
|
|
||||||
>
|
|
||||||
이전 로그 불러오기
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
|
|
||||||
<div class="log-grid">
|
|
||||||
<div v-for="type in logTypes" :key="type" class="log-block">
|
|
||||||
<div class="log-title">{{ logLabels[type] }}</div>
|
|
||||||
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
|
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
|
|
||||||
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
|
|
||||||
<button
|
|
||||||
v-if="logHasMore[type]"
|
|
||||||
class="ghost log-more"
|
|
||||||
@click="loadLog(type, logs[type].at(-1)?.id)"
|
|
||||||
>
|
|
||||||
이전 로그 불러오기
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</PanelCard>
|
<dt>무력</dt>
|
||||||
|
<dd>{{ data.general.stats.strength }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>지력</dt>
|
||||||
|
<dd>{{ data.general.stats.intelligence }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>소속</dt>
|
||||||
|
<dd>{{ data.nation?.name ?? '재야' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>도시</dt>
|
||||||
|
<dd>{{ data.city?.name ?? '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>금/쌀</dt>
|
||||||
|
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>병력</dt>
|
||||||
|
<dd>{{ data.general.crew }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>훈련/사기</dt>
|
||||||
|
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>경험/공헌</dt>
|
||||||
|
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-column">
|
||||||
|
<div class="setting-line">
|
||||||
|
토너먼트 【
|
||||||
|
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
|
||||||
|
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
|
||||||
|
】
|
||||||
|
</div>
|
||||||
|
<div class="hint">∞ 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
|
||||||
|
|
||||||
|
<label class="setting-line">
|
||||||
|
환약 사용 【
|
||||||
|
<select v-model.number="form.use_treatment">
|
||||||
|
<option :value="10">경상</option>
|
||||||
|
<option :value="21">중상</option>
|
||||||
|
<option :value="41">심각</option>
|
||||||
|
<option :value="61">위독</option>
|
||||||
|
<option :value="100">사용안함</option>
|
||||||
|
</select>
|
||||||
|
】
|
||||||
|
</label>
|
||||||
|
<div class="hint">∞ 부상을 입었을 때 환약을 사용하는 기준입니다.</div>
|
||||||
|
|
||||||
|
<label v-if="showAutoNationTurn" class="setting-line">
|
||||||
|
자동 사령턴 허용 【
|
||||||
|
<select v-model.number="form.use_auto_nation_turn">
|
||||||
|
<option :value="1">허용</option>
|
||||||
|
<option :value="0">허용 안함</option>
|
||||||
|
</select>
|
||||||
|
】
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="setting-line">
|
||||||
|
수비 【
|
||||||
|
<select v-model.number="form.defence_train">
|
||||||
|
<option :value="90">수비 함(훈사90)</option>
|
||||||
|
<option :value="80">수비 함(훈사80)</option>
|
||||||
|
<option :value="60">수비 함(훈사60)</option>
|
||||||
|
<option :value="40">수비 함(훈사40)</option>
|
||||||
|
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
|
||||||
|
</select>
|
||||||
|
】
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="set_my_setting"
|
||||||
|
class="action-button"
|
||||||
|
type="button"
|
||||||
|
:hidden="!canSave"
|
||||||
|
@click="saveSettings"
|
||||||
|
>
|
||||||
|
설정저장
|
||||||
|
</button>
|
||||||
|
<div class="hint">∞ 설정저장은 이달중 {{ data?.settings.myset ?? 0 }}회 남았습니다.</div>
|
||||||
|
|
||||||
|
<div v-if="penalties.length" class="penalties">
|
||||||
|
징계 목록(저장 시 갱신)
|
||||||
|
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showVacation" class="action-line">
|
||||||
|
휴 가 신 청<br />
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
type="button"
|
||||||
|
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
|
||||||
|
>
|
||||||
|
휴가 신청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
||||||
|
가오픈 기간 내 장수 삭제<br />
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
|
||||||
|
>
|
||||||
|
장수 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
|
||||||
|
서버 개시 이전 거병(2턴부터 건국 가능)<br />
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
@click="
|
||||||
|
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
|
||||||
|
trpc.general.buildNationCandidate.mutate()
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
사전 거병
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="actionAvailability.instantRetreat" class="action-line">
|
||||||
|
거리 3칸 이내 아국 도시로 즉시 이동<br />
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
@click="
|
||||||
|
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
|
||||||
|
"
|
||||||
|
>
|
||||||
|
접경 귀환
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screen-mode-row">
|
||||||
|
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
|
||||||
|
<div class="button-group">
|
||||||
|
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
|
||||||
|
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
|
||||||
|
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-title">아이템 파기</div>
|
||||||
|
<div class="item-group">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
:disabled="!item.code"
|
||||||
|
@click="dropItem(item)"
|
||||||
|
>
|
||||||
|
{{ item.code ?? '-' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="custom-css">
|
||||||
|
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
|
||||||
|
<textarea id="custom_css" v-model="customCss" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="log-grid">
|
||||||
|
<article v-for="type in logTypes" :key="type" class="log-panel">
|
||||||
|
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
|
||||||
|
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
|
||||||
|
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
|
||||||
|
<button
|
||||||
|
v-if="logHasMore[type]"
|
||||||
|
class="load-old"
|
||||||
|
type="button"
|
||||||
|
@click="loadLog(type, logs[type].at(-1)?.id)"
|
||||||
|
>
|
||||||
|
이전 로그 불러오기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.my-page {
|
.legacy-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
min-width: 500px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #111;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
.legacy-page.screen-500px {
|
||||||
.my-page.screen-500px {
|
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
.legacy-page.screen-1000px {
|
||||||
.my-page.screen-1000px {
|
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
}
|
}
|
||||||
|
.title-row {
|
||||||
.page-header {
|
height: 54px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-content: flex-start;
|
||||||
justify-content: space-between;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0 4px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.title-row > span {
|
||||||
.page-title {
|
flex-basis: 100%;
|
||||||
font-size: 1.6rem;
|
height: 18px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.legacy-button,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
border: 1px solid #777;
|
||||||
|
border-radius: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: #6b6b6b;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.legacy-button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-color: #2d5d7f;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #315f86;
|
||||||
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
button {
|
||||||
.page-subtitle {
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stack {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.5);
|
|
||||||
background: rgba(12, 12, 12, 0.6);
|
|
||||||
color: inherit;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.status-row,
|
||||||
|
.error-row {
|
||||||
|
padding: 4px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.error-row {
|
||||||
.action-btn.link {
|
color: #ff7777;
|
||||||
display: inline-flex;
|
border: 1px solid #a33;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
.top-grid,
|
||||||
.item-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-btn {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
background: rgba(12, 12, 12, 0.6);
|
|
||||||
color: inherit;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-grid {
|
.log-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
.general-column,
|
||||||
.log-block {
|
.settings-column,
|
||||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
.log-panel {
|
||||||
padding: 8px;
|
border: 1px solid #666;
|
||||||
background: rgba(12, 12, 12, 0.6);
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
min-height: 160px;
|
|
||||||
}
|
}
|
||||||
|
.section-title,
|
||||||
.log-title {
|
.log-panel h2 {
|
||||||
font-weight: 600;
|
min-height: 34px;
|
||||||
margin-bottom: 6px;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 1px solid #666;
|
||||||
|
background-color: #14241b;
|
||||||
|
background-image: url('/image/game/back_green.jpg');
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.sky {
|
||||||
.log-line {
|
color: skyblue;
|
||||||
padding: 4px 0;
|
|
||||||
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
|
|
||||||
}
|
}
|
||||||
|
.general-table {
|
||||||
.log-line:last-child {
|
display: grid;
|
||||||
border-bottom: none;
|
grid-template-columns: 150px 1fr;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #172a52;
|
||||||
|
background-image: url('/image/game/back_blue.jpg');
|
||||||
}
|
}
|
||||||
|
.portrait-cell {
|
||||||
.log-more {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-right: 1px solid #777;
|
||||||
|
}
|
||||||
|
.portrait-cell img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
border-bottom: 1px solid #777;
|
||||||
|
}
|
||||||
|
dt,
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-right: 1px solid #777;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.settings-column {
|
||||||
|
padding: 10px 18px;
|
||||||
|
}
|
||||||
|
.setting-line {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 13px;
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
width: 160px;
|
||||||
|
height: 30px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: #225500;
|
||||||
|
}
|
||||||
|
.action-line {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.penalties {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: #f66;
|
||||||
|
}
|
||||||
|
.screen-mode-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.button-group label {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
background: #26384d;
|
||||||
|
}
|
||||||
|
.button-group input {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.item-title {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.item-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
margin: 5px 0 14px;
|
||||||
|
}
|
||||||
|
.item-group button {
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
.custom-css {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.custom-css textarea {
|
||||||
|
display: block;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.log-panel {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
.log-panel h2 {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
.log-line,
|
||||||
|
.empty,
|
||||||
|
.loading {
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.load-old {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 991px) {
|
||||||
.log-tabs {
|
.legacy-page {
|
||||||
display: flex;
|
width: 500px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-tabs button {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-tabs button.active {
|
|
||||||
background: rgba(201, 164, 90, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghost {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.5);
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: rgba(232, 221, 196, 0.6);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #f08a5d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.layout-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
|
.top-grid,
|
||||||
.log-grid {
|
.log-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listItems = computed(() =>
|
const listItems = computed(() =>
|
||||||
list.value
|
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
|
||||||
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
|
|
||||||
: []
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const info = computed(() => detail.value?.bettingInfo ?? null);
|
const info = computed(() => detail.value?.bettingInfo ?? null);
|
||||||
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
|
|||||||
|
|
||||||
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
||||||
|
|
||||||
const totalAmount = computed(() =>
|
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
|
||||||
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const pureAmount = computed(() =>
|
const pureAmount = computed(() =>
|
||||||
(detail.value?.bettingDetail ?? []).reduce(
|
(detail.value?.bettingDetail ?? []).reduce(
|
||||||
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const usedAmount = computed(() =>
|
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
|
||||||
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
|
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
|
||||||
|
|
||||||
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
|
|||||||
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseYearMonth = (yearMonth: number): [number, number] => [
|
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
|
||||||
Math.floor(yearMonth / 12),
|
|
||||||
(yearMonth % 12) + 1,
|
|
||||||
];
|
|
||||||
|
|
||||||
const readSelection = (value: string): number[] => {
|
const readSelection = (value: string): number[] => {
|
||||||
try {
|
try {
|
||||||
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
|
|||||||
.map((index) => candidates.value[index]?.title ?? '-')
|
.map((index) => candidates.value[index]?.title ?? '-')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
const isListOpen = (item: BettingListItem): boolean =>
|
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
|
||||||
!item.finished && listYearMonth.value <= item.closeYearMonth;
|
|
||||||
|
|
||||||
const isDetailOpen = computed(() =>
|
const isDetailOpen = computed(() =>
|
||||||
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
|
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
|
||||||
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
|
|||||||
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
|
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedMultiplier = (key: string, betAmount: number): string => {
|
const rewardByMatch = computed(() => {
|
||||||
if (betAmount <= 0) {
|
const selectCount = info.value?.selectCnt ?? 0;
|
||||||
return '0.0';
|
const rewards = new Array<number>(selectCount + 1).fill(0);
|
||||||
|
if (selectCount <= 0) {
|
||||||
|
return rewards;
|
||||||
}
|
}
|
||||||
|
const amountByMatch = new Map<number, number>();
|
||||||
|
for (const [key, betAmount] of detailRows.value) {
|
||||||
|
const matched = matchCount(key);
|
||||||
|
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
|
||||||
|
}
|
||||||
|
if (selectCount === 1 || info.value?.isExclusive) {
|
||||||
|
rewards[selectCount] = totalAmount.value;
|
||||||
|
return rewards;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingReward = totalAmount.value;
|
||||||
|
let accumulatedReward = 0;
|
||||||
|
let nextReward = totalAmount.value;
|
||||||
|
for (let matched = selectCount; matched > 0; matched -= 1) {
|
||||||
|
nextReward /= 2;
|
||||||
|
accumulatedReward += nextReward;
|
||||||
|
if (!amountByMatch.has(matched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rewards[matched] = accumulatedReward;
|
||||||
|
remainingReward -= accumulatedReward;
|
||||||
|
accumulatedReward = 0;
|
||||||
|
}
|
||||||
|
for (let matched = selectCount; matched >= 0; matched -= 1) {
|
||||||
|
if (!amountByMatch.has(matched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rewards[matched] += remainingReward;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return rewards;
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedReward = (key: string): number => {
|
||||||
if (!info.value?.finished) {
|
if (!info.value?.finished) {
|
||||||
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
||||||
return (reward / betAmount).toFixed(1);
|
|
||||||
}
|
}
|
||||||
const matched = matchCount(key);
|
return rewardByMatch.value[matchCount(key)] ?? 0;
|
||||||
const matchedAmount = detailRows.value
|
};
|
||||||
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
|
|
||||||
.reduce((sum, [, value]) => sum + value, 0);
|
const rewardDivisor = (key: string, betAmount: number): number =>
|
||||||
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
|
info.value?.finished
|
||||||
|
? detailRows.value
|
||||||
|
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
|
||||||
|
.reduce((sum, [, value]) => sum + value, 0)
|
||||||
|
: betAmount;
|
||||||
|
|
||||||
|
const expectedMultiplier = (key: string, betAmount: number): string => {
|
||||||
|
const divisor = rewardDivisor(key, betAmount);
|
||||||
|
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const myExpectedReward = (key: string, betAmount: number): string => {
|
||||||
|
const myAmount = myBetMap.value.get(key);
|
||||||
|
if (myAmount === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const divisor = rewardDivisor(key, betAmount);
|
||||||
|
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
|
||||||
|
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadList = async () => {
|
const loadList = async () => {
|
||||||
@@ -295,7 +338,7 @@ onMounted(() => {
|
|||||||
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
||||||
<header class="legacy-top-bar">
|
<header class="legacy-top-bar">
|
||||||
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
||||||
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
|
<div></div>
|
||||||
<h1>국가 베팅장</h1>
|
<h1>국가 베팅장</h1>
|
||||||
<div></div>
|
<div></div>
|
||||||
<div></div>
|
<div></div>
|
||||||
@@ -309,32 +352,37 @@ onMounted(() => {
|
|||||||
{{ info.name }}
|
{{ info.name }}
|
||||||
<span v-if="info.finished">(종료)</span>
|
<span v-if="info.finished">(종료)</span>
|
||||||
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
||||||
({{ parseYearMonth(info.closeYearMonth)[0] }}년
|
({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
||||||
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>(베팅 마감)</span>
|
<span v-else>(베팅 마감)</span>
|
||||||
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="betting-candidates">
|
<div class="betting-candidates">
|
||||||
<button
|
<div
|
||||||
v-for="(candidate, index) in candidates"
|
v-for="(candidate, index) in candidates"
|
||||||
:key="`${info.id}-${index}`"
|
:key="`${info.id}-${index}`"
|
||||||
type="button"
|
class="betting-candidate-cell"
|
||||||
class="betting-candidate"
|
|
||||||
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
|
|
||||||
:disabled="!isDetailOpen"
|
|
||||||
@click="toggleCandidate(index)"
|
|
||||||
>
|
>
|
||||||
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
<button
|
||||||
<span class="candidate-info">
|
type="button"
|
||||||
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
class="betting-candidate"
|
||||||
</span>
|
:class="{
|
||||||
<span class="candidate-rate">
|
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
|
||||||
선택율:
|
}"
|
||||||
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
:disabled="!isDetailOpen"
|
||||||
</span>
|
@click="toggleCandidate(index)"
|
||||||
</button>
|
>
|
||||||
|
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
||||||
|
<span class="candidate-info">
|
||||||
|
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="candidate-rate">
|
||||||
|
선택율:
|
||||||
|
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
||||||
@@ -361,7 +409,7 @@ onMounted(() => {
|
|||||||
{{ selectionLabel(key) }}
|
{{ selectionLabel(key) }}
|
||||||
</div>
|
</div>
|
||||||
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
|
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
|
||||||
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
|
<div>{{ myExpectedReward(key, betAmount) }}</div>
|
||||||
<div>{{ expectedMultiplier(key, betAmount) }}배</div>
|
<div>{{ expectedMultiplier(key, betAmount) }}배</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,8 +430,7 @@ onMounted(() => {
|
|||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
<span v-if="item.finished">(종료)</span>
|
<span v-if="item.finished">(종료)</span>
|
||||||
<span v-else-if="isListOpen(item)">
|
<span v-else-if="isListOpen(item)">
|
||||||
({{ parseYearMonth(item.closeYearMonth)[0] }}년
|
({{ parseYearMonth(item.closeYearMonth)[0] }}년 {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
||||||
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>(베팅 마감)</span>
|
<span v-else>(베팅 마감)</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -400,12 +447,11 @@ onMounted(() => {
|
|||||||
.nation-betting-page {
|
.nation-betting-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 500px;
|
width: 500px;
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.3;
|
line-height: 1.5;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,19 +504,32 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
min-height: 22px;
|
min-height: 21px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 22px;
|
line-height: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidates {
|
.betting-candidates {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
margin-top: -3.5px;
|
||||||
padding: 4px;
|
margin-right: -1.75px;
|
||||||
|
margin-left: -1.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betting-candidate-cell {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 33.33333333%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-right: 1.75px;
|
||||||
|
padding-left: 1.75px;
|
||||||
|
margin-top: 3.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidate {
|
.betting-candidate {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 143px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
@@ -530,7 +589,7 @@ onMounted(() => {
|
|||||||
.betting-form input {
|
.betting-form input {
|
||||||
grid-column: span 4;
|
grid-column: span 4;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 30px;
|
height: 35.5px;
|
||||||
border: 1px solid #777;
|
border: 1px solid #777;
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
color: #303030;
|
color: #303030;
|
||||||
@@ -538,10 +597,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-form button {
|
.betting-form button {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
|
height: 35.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-table {
|
.payout-table {
|
||||||
margin-top: 6px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-row {
|
.payout-row {
|
||||||
@@ -551,7 +611,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.payout-row > div {
|
.payout-row > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 2px 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-row > div:not(:first-child) {
|
.payout-row > div:not(:first-child) {
|
||||||
@@ -572,7 +632,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-item {
|
.betting-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: auto;
|
||||||
margin: 0.25em;
|
margin: 0.25em;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -595,6 +655,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-footer .legacy-nav-button {
|
.betting-footer .legacy-nav-button {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
|
height: 35.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-notice,
|
.betting-notice,
|
||||||
@@ -602,6 +663,15 @@ onMounted(() => {
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.betting-notice {
|
||||||
|
position: fixed;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
width: min(320px, calc(100vw - 16px));
|
||||||
|
background: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
.betting-notice.error {
|
.betting-notice.error {
|
||||||
border: 1px solid #9b4848;
|
border: 1px solid #9b4848;
|
||||||
color: #ffd0d0;
|
color: #ffd0d0;
|
||||||
@@ -617,8 +687,9 @@ onMounted(() => {
|
|||||||
width: 1000px;
|
width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidates {
|
.betting-candidate-cell {
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
|
||||||
|
width: 16.66666667%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-form {
|
.betting-form {
|
||||||
|
|||||||
@@ -1,349 +1,226 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
||||||
import { trpc } from '../utils/trpc';
|
|
||||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type GeneralListResponse = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
||||||
|
type General = Result['generals'][number];
|
||||||
type GeneralEntry = GeneralListResponse['generals'][number];
|
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||||
|
const data = ref<Result | null>(null);
|
||||||
type SortKey =
|
const error = ref('');
|
||||||
| 1
|
|
||||||
| 2
|
|
||||||
| 3
|
|
||||||
| 4
|
|
||||||
| 5
|
|
||||||
| 6
|
|
||||||
| 7
|
|
||||||
| 8
|
|
||||||
| 9
|
|
||||||
| 10
|
|
||||||
| 11
|
|
||||||
| 12
|
|
||||||
| 13
|
|
||||||
| 14
|
|
||||||
| 15;
|
|
||||||
|
|
||||||
const sortOptions: Array<{ key: SortKey; label: string }> = [
|
|
||||||
{ key: 1, label: '관직' },
|
|
||||||
{ key: 2, label: '공헌' },
|
|
||||||
{ key: 3, label: '경험' },
|
|
||||||
{ key: 4, label: '통솔' },
|
|
||||||
{ key: 5, label: '무력' },
|
|
||||||
{ key: 6, label: '지력' },
|
|
||||||
{ key: 7, label: '자금' },
|
|
||||||
{ key: 8, label: '군량' },
|
|
||||||
{ key: 9, label: '병사' },
|
|
||||||
{ key: 10, label: '벌점' },
|
|
||||||
{ key: 11, label: '성격' },
|
|
||||||
{ key: 12, label: '내특' },
|
|
||||||
{ key: 13, label: '전특' },
|
|
||||||
{ key: 14, label: '사관' },
|
|
||||||
{ key: 15, label: 'NPC' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const sort = ref<Sort>(1);
|
||||||
const data = ref<GeneralListResponse | null>(null);
|
const options = [
|
||||||
const sortKey = ref<SortKey>(1);
|
'관직',
|
||||||
const filterText = ref('');
|
'계급',
|
||||||
|
'명성',
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
'통솔',
|
||||||
if (value instanceof Error) {
|
'무력',
|
||||||
return value.message;
|
'지력',
|
||||||
}
|
'자금',
|
||||||
if (typeof value === 'string') {
|
'군량',
|
||||||
return value;
|
'병사',
|
||||||
}
|
'벌점',
|
||||||
return 'unknown_error';
|
'성격',
|
||||||
};
|
'내특',
|
||||||
|
'전특',
|
||||||
const loadGenerals = async () => {
|
'사관',
|
||||||
if (loading.value) {
|
'NPC',
|
||||||
return;
|
];
|
||||||
}
|
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
||||||
|
const load = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data.value = await trpc.nation.getGeneralList.query();
|
data.value = await trpc.nation.getGeneralList.query();
|
||||||
} catch (err) {
|
} catch (cause) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.';
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const generals = computed(() =>
|
||||||
const sortGenerals = (list: GeneralEntry[]): GeneralEntry[] => {
|
[...(data.value?.generals ?? [])].sort((a, b) => {
|
||||||
const key = sortKey.value;
|
if (sort.value === 1) return b.officerLevel - a.officerLevel || a.id - b.id;
|
||||||
const sorted = [...list].sort((lhs, rhs) => {
|
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
|
||||||
switch (key) {
|
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
|
||||||
case 1:
|
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
|
||||||
return rhs.officerLevel - lhs.officerLevel;
|
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
|
||||||
case 2:
|
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
|
||||||
return rhs.dedication - lhs.dedication;
|
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
|
||||||
case 3:
|
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
|
||||||
return rhs.experience - lhs.experience;
|
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
|
||||||
case 4:
|
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
|
||||||
return rhs.stats.leadership - lhs.stats.leadership;
|
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
|
||||||
case 5:
|
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
|
||||||
return rhs.stats.strength - lhs.stats.strength;
|
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
|
||||||
case 6:
|
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
|
||||||
return rhs.stats.intelligence - lhs.stats.intelligence;
|
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
|
||||||
case 7:
|
return a.id - b.id;
|
||||||
return rhs.gold - lhs.gold;
|
})
|
||||||
case 8:
|
);
|
||||||
return rhs.rice - lhs.rice;
|
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
||||||
case 9:
|
onMounted(load);
|
||||||
return rhs.crew - lhs.crew;
|
|
||||||
case 10:
|
|
||||||
return 0;
|
|
||||||
case 11:
|
|
||||||
return (lhs.personality?.name ?? '').localeCompare(rhs.personality?.name ?? '');
|
|
||||||
case 12:
|
|
||||||
return (lhs.specialDomestic?.name ?? '').localeCompare(rhs.specialDomestic?.name ?? '');
|
|
||||||
case 13:
|
|
||||||
return (lhs.specialWar?.name ?? '').localeCompare(rhs.specialWar?.name ?? '');
|
|
||||||
case 14:
|
|
||||||
return rhs.belong - lhs.belong;
|
|
||||||
case 15:
|
|
||||||
return rhs.npcState - lhs.npcState;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (key === 11 || key === 12 || key === 13) {
|
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredGenerals = computed(() => {
|
|
||||||
const list = data.value?.generals ?? [];
|
|
||||||
const keyword = filterText.value.trim().toLowerCase();
|
|
||||||
const filtered = keyword
|
|
||||||
? list.filter((general) => {
|
|
||||||
return (
|
|
||||||
general.name.toLowerCase().includes(keyword) ||
|
|
||||||
(general.cityName ?? '').toLowerCase().includes(keyword) ||
|
|
||||||
(general.officerCityName ?? '').toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: list;
|
|
||||||
|
|
||||||
return sortGenerals(filtered);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nationLevel = computed(() => data.value?.nation.level ?? 0);
|
|
||||||
|
|
||||||
const formatSpecial = (general: GeneralEntry): string => {
|
|
||||||
const domestic = general.specialDomestic?.name ?? '-';
|
|
||||||
const war = general.specialWar?.name ?? '-';
|
|
||||||
return `${domestic} / ${war}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadGenerals();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="nation-page">
|
<main class="general-page legacy-bg0">
|
||||||
<header class="page-header">
|
<header>
|
||||||
<div>
|
<strong>세력 장수</strong>
|
||||||
<h1 class="page-title">세력 장수</h1>
|
<span
|
||||||
<p class="page-subtitle">세력 내 장수 현황 및 정렬</p>
|
><RouterLink to="/">돌아가기</RouterLink>
|
||||||
</div>
|
<button :disabled="loading" @click="load">새로고침</button></span
|
||||||
<div class="header-actions">
|
>
|
||||||
<RouterLink class="ghost" to="/">메인</RouterLink>
|
|
||||||
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
|
||||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
|
||||||
<button class="ghost" @click="loadGenerals">새로고침</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
<section class="sort">
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
정렬순서 :
|
||||||
|
<select v-model.number="sort" aria-label="세력 장수 정렬">
|
||||||
<PanelCard title="세력 장수 목록" subtitle="국가 소속 장수들을 확인합니다.">
|
<option v-for="(label, index) in options" :key="label" :value="index + 1">{{ label }}</option>
|
||||||
<template #actions>
|
</select>
|
||||||
<div class="toolbar-actions">
|
<button>정렬하기</button>
|
||||||
<select v-model.number="sortKey" class="select-input">
|
<small v-if="data">열람 등급 {{ data.viewer.permission }}</small>
|
||||||
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
|
</section>
|
||||||
{{ option.label }}
|
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
||||||
</option>
|
<p v-else-if="loading" class="state">불러오는 중...</p>
|
||||||
</select>
|
<div v-else class="scroll">
|
||||||
<input v-model="filterText" class="filter-input" placeholder="이름/도시 검색" />
|
<table id="nation-general-list">
|
||||||
</div>
|
<thead>
|
||||||
</template>
|
<tr>
|
||||||
|
<th>이 름</th>
|
||||||
<div class="list-meta">총 {{ filteredGenerals.length }}명</div>
|
<th>관 직</th>
|
||||||
|
<th>통무지</th>
|
||||||
<SkeletonLines v-if="loading" :lines="6" />
|
<th>명성/계급</th>
|
||||||
<div v-else class="table-scroll">
|
<th>자금</th>
|
||||||
<table class="nation-table">
|
<th>군량</th>
|
||||||
<thead>
|
<th>도시</th>
|
||||||
<tr>
|
<th>부대</th>
|
||||||
<th>이름</th>
|
<th>병사</th>
|
||||||
<th>관직</th>
|
<th>성격</th>
|
||||||
<th>공헌</th>
|
<th>특기</th>
|
||||||
<th>경험</th>
|
<th>사관</th>
|
||||||
<th>통솔</th>
|
<th>벌점</th>
|
||||||
<th>무력</th>
|
</tr>
|
||||||
<th>지력</th>
|
</thead>
|
||||||
<th>자금</th>
|
<tbody>
|
||||||
<th>군량</th>
|
<tr v-for="general in generals" :key="general.id">
|
||||||
<th>병사</th>
|
<td :class="`npc-${general.npcState}`">{{ general.name }}</td>
|
||||||
<th>성격</th>
|
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
|
||||||
<th>특기</th>
|
<td>
|
||||||
<th>사관</th>
|
{{ general.stats.leadership }}∥{{ general.stats.strength }}∥{{ general.stats.intelligence }}
|
||||||
<th>현재 도시</th>
|
</td>
|
||||||
<th>관직 도시</th>
|
<td>
|
||||||
</tr>
|
Lv {{ general.experienceLevel }}<br />{{
|
||||||
</thead>
|
general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'
|
||||||
<tbody>
|
}}
|
||||||
<tr v-for="general in filteredGenerals" :key="general.id">
|
</td>
|
||||||
<td>
|
<td>{{ general.gold.toLocaleString() }}</td>
|
||||||
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
|
<td>{{ general.rice.toLocaleString() }}</td>
|
||||||
{{ general.name }}
|
<td>{{ general.cityName ?? '?' }}</td>
|
||||||
</td>
|
<td>{{ general.troopName ?? '?' }}</td>
|
||||||
<td>{{ formatOfficerLevelText(general.officerLevel, nationLevel) }}</td>
|
<td>{{ visibleCrew(general)?.toLocaleString() ?? '?' }}</td>
|
||||||
<td>{{ general.dedication }}</td>
|
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
|
||||||
<td>{{ general.experience }}</td>
|
<td
|
||||||
<td>{{ general.stats.leadership }}</td>
|
:title="
|
||||||
<td>{{ general.stats.strength }}</td>
|
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
|
||||||
<td>{{ general.stats.intelligence }}</td>
|
"
|
||||||
<td>{{ general.gold }}</td>
|
>
|
||||||
<td>{{ general.rice }}</td>
|
{{ special(general) }}
|
||||||
<td>{{ general.crew }}</td>
|
</td>
|
||||||
<td>{{ general.personality?.name ?? '-' }}</td>
|
<td>{{ general.belong }}</td>
|
||||||
<td>{{ formatSpecial(general) }}</td>
|
<td>{{ general.refreshScoreTotal }}</td>
|
||||||
<td>{{ general.belong > 0 ? general.belong : '-' }}</td>
|
</tr>
|
||||||
<td>{{ general.cityName ?? '-' }}</td>
|
</tbody>
|
||||||
<td>{{ general.officerCityName ?? '-' }}</td>
|
</table>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
<footer><RouterLink to="/">돌아가기</RouterLink></footer>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.nation-page {
|
.general-page {
|
||||||
|
width: 1000px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
margin: 8px auto 0;
|
||||||
|
font:
|
||||||
|
16px 'Times New Roman',
|
||||||
|
serif;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
header,
|
||||||
|
.sort,
|
||||||
|
footer,
|
||||||
|
.state {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #777;
|
||||||
|
padding: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
min-height: 39px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghost {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #f5b7b1;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
background: rgba(16, 16, 16, 0.8);
|
|
||||||
color: rgba(232, 221, 196, 0.9);
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
background: rgba(16, 16, 16, 0.8);
|
|
||||||
color: rgba(232, 221, 196, 0.9);
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-meta {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: rgba(232, 221, 196, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-scroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
max-height: 70vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nation-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nation-table th,
|
|
||||||
.nation-table td {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nation-table thead th {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: rgba(232, 221, 196, 0.6);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.npc-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.6rem;
|
}
|
||||||
padding: 2px 4px;
|
header span {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #222;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.sort small {
|
||||||
|
float: right;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
color: #ccc;
|
||||||
color: rgba(232, 221, 196, 0.8);
|
}
|
||||||
|
.scroll {
|
||||||
|
width: 1030px;
|
||||||
|
margin-left: -15px;
|
||||||
|
min-height: calc(100vh - 112px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 1030px;
|
||||||
|
min-width: 1030px;
|
||||||
|
border-collapse: separate;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #777;
|
||||||
|
padding: 3px;
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
height: 30px;
|
||||||
|
background: #14241b url('/image/game/back_green.jpg');
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
tbody tr {
|
||||||
|
height: 66px;
|
||||||
|
background: rgb(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
.npc-1 {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
.npc-2,
|
||||||
|
.npc-3,
|
||||||
|
.npc-4,
|
||||||
|
.npc-5 {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ff7373;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.general-page {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
||||||
|
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||||
|
const data = ref<Result | null>(null);
|
||||||
|
const error = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const sort = ref<Sort>(7);
|
||||||
|
const options = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
data.value = await trpc.nation.getSecretGeneralList.query();
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = cause instanceof Error ? cause.message : '암행부를 불러오지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const generals = computed(() =>
|
||||||
|
[...(data.value?.generals ?? [])].sort((a, b) => {
|
||||||
|
if (sort.value === 1) return b.gold - a.gold || a.id - b.id;
|
||||||
|
if (sort.value === 2) return b.rice - a.rice || a.id - b.id;
|
||||||
|
if (sort.value === 3) return a.cityId - b.cityId || a.id - b.id;
|
||||||
|
if (sort.value === 4) return b.crewTypeId - a.crewTypeId || a.id - b.id;
|
||||||
|
if (sort.value === 5) return b.crew - a.crew || a.id - b.id;
|
||||||
|
if (sort.value === 6) return a.killTurn - b.killTurn || a.id - b.id;
|
||||||
|
if (sort.value === 7) return a.turnTime.localeCompare(b.turnTime) || a.id - b.id;
|
||||||
|
return b.troopId - a.troopId || a.id - b.id;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="secret-page">
|
||||||
|
<table class="layout legacy-bg0 title">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>암 행 부<br /><RouterLink to="/">창 닫기</RouterLink></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
정렬순서 :
|
||||||
|
<select v-model.number="sort" aria-label="암행부 정렬">
|
||||||
|
<option v-for="(label, index) in options" :key="label" :value="index + 1">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button>정렬하기</button> <button :disabled="loading" @click="load">새로고침</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="error" class="state error legacy-bg0" role="alert">{{ error }}</p>
|
||||||
|
<p v-else-if="loading" class="state legacy-bg0">불러오는 중...</p>
|
||||||
|
<template v-else-if="data">
|
||||||
|
<table class="layout summary legacy-bg0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>전체 금</th>
|
||||||
|
<td>{{ data.summary.gold.toLocaleString() }}</td>
|
||||||
|
<th>전체 쌀</th>
|
||||||
|
<td>{{ data.summary.rice.toLocaleString() }}</td>
|
||||||
|
<th>평균 금</th>
|
||||||
|
<td>{{ data.summary.averageGold.toFixed(2) }}</td>
|
||||||
|
<th>평균 쌀</th>
|
||||||
|
<td>{{ data.summary.averageRice.toFixed(2) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>전체 병력/장수</th>
|
||||||
|
<td>{{ data.summary.crew.toLocaleString() }}/{{ data.summary.generalCount }}</td>
|
||||||
|
<template v-for="level in [90, 80, 60] as const" :key="level"
|
||||||
|
><th>훈사 {{ level }} 병력/장수</th>
|
||||||
|
<td>
|
||||||
|
{{ data.summary.readiness[level].crew.toLocaleString() }}/{{
|
||||||
|
data.summary.readiness[level].generals
|
||||||
|
}}
|
||||||
|
</td></template
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table id="secret-general-list" class="layout list legacy-bg0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이 름</th>
|
||||||
|
<th>통무지</th>
|
||||||
|
<th>부 대</th>
|
||||||
|
<th>자 금</th>
|
||||||
|
<th>군 량</th>
|
||||||
|
<th>도시</th>
|
||||||
|
<th>守</th>
|
||||||
|
<th>병 종</th>
|
||||||
|
<th>병 사</th>
|
||||||
|
<th>훈련</th>
|
||||||
|
<th>사기</th>
|
||||||
|
<th class="commands">명 령</th>
|
||||||
|
<th>삭턴</th>
|
||||||
|
<th>턴</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="general in generals" :key="general.id">
|
||||||
|
<td>{{ general.name }}<br />Lv {{ general.experienceLevel }}</td>
|
||||||
|
<td>
|
||||||
|
{{ general.stats.leadership }}∥{{ general.stats.strength }}∥{{ general.stats.intelligence }}
|
||||||
|
</td>
|
||||||
|
<td>{{ general.troopName ?? '-' }}</td>
|
||||||
|
<td>{{ general.gold }}</td>
|
||||||
|
<td>{{ general.rice }}</td>
|
||||||
|
<td>{{ general.cityName ?? '-' }}</td>
|
||||||
|
<td>{{ general.defenceTrainText }}</td>
|
||||||
|
<td>{{ general.crewTypeId }}</td>
|
||||||
|
<td>{{ general.crew }}</td>
|
||||||
|
<td>{{ general.train }}</td>
|
||||||
|
<td>{{ general.atmos }}</td>
|
||||||
|
<td class="turns">
|
||||||
|
<template v-if="general.npcState >= 2">NPC 장수</template
|
||||||
|
><template v-else
|
||||||
|
><div v-for="(command, index) in general.reservedCommands" :key="index">
|
||||||
|
{{ index + 1 }} : {{ command }}
|
||||||
|
</div></template
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>{{ general.killTurn }}</td>
|
||||||
|
<td>{{ general.turnTime.slice(11, 16) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<table class="layout legacy-bg0 footer">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><RouterLink to="/">창 닫기</RouterLink></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.secret-page {
|
||||||
|
width: 1000px;
|
||||||
|
margin: 8px auto 0;
|
||||||
|
font:
|
||||||
|
16px 'Times New Roman',
|
||||||
|
serif;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.layout {
|
||||||
|
width: 1000px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
td,
|
||||||
|
th,
|
||||||
|
.state {
|
||||||
|
border: 1px solid #777;
|
||||||
|
padding: 3px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #222;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
margin: 5px auto;
|
||||||
|
}
|
||||||
|
.summary th,
|
||||||
|
.list th {
|
||||||
|
background: #14241b url('/image/game/back_green.jpg');
|
||||||
|
}
|
||||||
|
.summary th {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
width: 1030px;
|
||||||
|
margin-left: -15px;
|
||||||
|
border-collapse: separate;
|
||||||
|
}
|
||||||
|
.list tbody tr {
|
||||||
|
height: 39px;
|
||||||
|
}
|
||||||
|
.commands {
|
||||||
|
width: 213px;
|
||||||
|
}
|
||||||
|
.turns {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ff7373;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.secret-page {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,78 +1,61 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
||||||
import { trpc } from '../utils/trpc';
|
|
||||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
||||||
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
||||||
type PolicyKey = keyof NationPolicy;
|
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
|
||||||
type PolicyField = {
|
type PrioritySectionKey = 'nation' | 'general';
|
||||||
key: PolicyKey;
|
type PriorityBucket = 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface PolicyField {
|
||||||
|
key: NumericPolicyKey;
|
||||||
label: string;
|
label: string;
|
||||||
step: number;
|
step: number;
|
||||||
description: string;
|
description: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
percent?: boolean;
|
percent?: boolean;
|
||||||
};
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
type PolicySection = {
|
interface PriorityListState {
|
||||||
title: string;
|
|
||||||
fields: PolicyField[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const NUMERIC_POLICY_KEYS = [
|
|
||||||
'reqNationGold',
|
|
||||||
'reqNationRice',
|
|
||||||
'reqHumanWarUrgentGold',
|
|
||||||
'reqHumanWarUrgentRice',
|
|
||||||
'reqHumanWarRecommandGold',
|
|
||||||
'reqHumanWarRecommandRice',
|
|
||||||
'reqHumanDevelGold',
|
|
||||||
'reqHumanDevelRice',
|
|
||||||
'reqNPCWarGold',
|
|
||||||
'reqNPCWarRice',
|
|
||||||
'reqNPCDevelGold',
|
|
||||||
'reqNPCDevelRice',
|
|
||||||
'minimumResourceActionAmount',
|
|
||||||
'maximumResourceActionAmount',
|
|
||||||
'minNPCWarLeadership',
|
|
||||||
'minWarCrew',
|
|
||||||
'minNPCRecruitCityPopulation',
|
|
||||||
'safeRecruitCityPopulationRatio',
|
|
||||||
'properWarTrainAtmos',
|
|
||||||
'cureThreshold',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type NumericPolicyKey = (typeof NUMERIC_POLICY_KEYS)[number];
|
|
||||||
|
|
||||||
type PrioritySectionKey = 'nation' | 'general';
|
|
||||||
|
|
||||||
type PriorityListState = {
|
|
||||||
active: string[];
|
active: string[];
|
||||||
inactive: string[];
|
inactive: string[];
|
||||||
available: string[];
|
available: string[];
|
||||||
};
|
}
|
||||||
|
|
||||||
|
interface PriorityPanel {
|
||||||
|
key: PrioritySectionKey;
|
||||||
|
title: string;
|
||||||
|
description: string[];
|
||||||
|
setter: NpcPolicyResponse['lastSetters']['nation'];
|
||||||
|
state: PriorityListState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
section: PrioritySectionKey;
|
||||||
|
bucket: PriorityBucket;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
const notice = ref<string | null>(null);
|
||||||
const data = ref<NpcPolicyResponse | null>(null);
|
const data = ref<NpcPolicyResponse | null>(null);
|
||||||
const policyDraft = ref<NationPolicy | null>(null);
|
const policyDraft = ref<NationPolicy | null>(null);
|
||||||
const lastSavedPolicy = ref<NationPolicy | null>(null);
|
const lastSavedPolicy = ref<NationPolicy | null>(null);
|
||||||
|
|
||||||
const nationPriority = ref<PriorityListState | null>(null);
|
const nationPriority = ref<PriorityListState | null>(null);
|
||||||
const generalPriority = ref<PriorityListState | null>(null);
|
const generalPriority = ref<PriorityListState | null>(null);
|
||||||
const lastSavedNationPriority = ref<string[]>([]);
|
const lastSavedNationPriority = ref<string[]>([]);
|
||||||
const lastSavedGeneralPriority = ref<string[]>([]);
|
const lastSavedGeneralPriority = ref<string[]>([]);
|
||||||
|
const dragState = ref<DragState | null>(null);
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) return value.message;
|
||||||
return value.message;
|
if (typeof value === 'string') return value;
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return 'unknown_error';
|
return 'unknown_error';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,851 +68,870 @@ const clonePolicy = (source: NationPolicy): NationPolicy => ({
|
|||||||
|
|
||||||
const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
|
const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
|
||||||
const activeSet = new Set(active);
|
const activeSet = new Set(active);
|
||||||
const inactive = available.filter((item) => !activeSet.has(item));
|
|
||||||
return {
|
return {
|
||||||
active: [...active],
|
active: [...active],
|
||||||
inactive,
|
inactive: available.filter((item) => !activeSet.has(item)),
|
||||||
available: [...available],
|
available: [...available],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPolicy = async () => {
|
const loadPolicy = async () => {
|
||||||
if (loading.value) {
|
if (loading.value) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data.value = await trpc.npc.getPolicy.query();
|
data.value = await trpc.npc.getPolicy.query();
|
||||||
} catch (err) {
|
} catch (caught) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(caught);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
watch(data, (value) => {
|
||||||
void loadPolicy();
|
if (!value) return;
|
||||||
|
policyDraft.value = clonePolicy(value.currentNationPolicy);
|
||||||
|
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
|
||||||
|
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
|
||||||
|
generalPriority.value = assignPriorityState(
|
||||||
|
value.currentGeneralActionPriority,
|
||||||
|
value.availableGeneralActionPriorityItems
|
||||||
|
);
|
||||||
|
lastSavedNationPriority.value = [...value.currentNationPriority];
|
||||||
|
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
onMounted(() => void loadPolicy());
|
||||||
() => data.value,
|
|
||||||
(value) => {
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
policyDraft.value = clonePolicy(value.currentNationPolicy);
|
|
||||||
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
|
|
||||||
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
|
|
||||||
generalPriority.value = assignPriorityState(
|
|
||||||
value.currentGeneralActionPriority,
|
|
||||||
value.availableGeneralActionPriorityItems
|
|
||||||
);
|
|
||||||
lastSavedNationPriority.value = [...value.currentNationPriority];
|
|
||||||
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
|
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
|
||||||
|
|
||||||
const calcPolicyValue = (key: NumericPolicyKey): number => {
|
const calcPolicyValue = (key: NumericPolicyKey): number => {
|
||||||
if (!data.value || !policyDraft.value) {
|
if (!data.value || !policyDraft.value) return 0;
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const value = policyDraft.value[key];
|
const value = policyDraft.value[key];
|
||||||
if (value === 0) {
|
return value === 0 ? data.value.zeroPolicy[key] : value;
|
||||||
return data.value.zeroPolicy[key];
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeRecruitPercent = computed({
|
const safeRecruitPercent = computed({
|
||||||
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
|
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
|
||||||
set: (value: number) => {
|
set: (value: number) => {
|
||||||
if (!policyDraft.value) {
|
if (policyDraft.value) policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
|
||||||
return;
|
|
||||||
}
|
|
||||||
policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const policySections = computed<PolicySection[]>(() => {
|
const policyFields = computed<PolicyField[]>(() => {
|
||||||
if (!data.value) {
|
if (!data.value) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const statMax = data.value.defaultStatMax;
|
const statMax = data.value.defaultStatMax;
|
||||||
const statNpcMax = data.value.defaultStatNpcMax;
|
const statNpcMax = data.value.defaultStatNpcMax;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: '국가 재정',
|
key: 'reqNationGold',
|
||||||
fields: [
|
label: '국가 권장 금',
|
||||||
{
|
step: 100,
|
||||||
key: 'reqNationGold',
|
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
||||||
label: '국가 권장 금',
|
|
||||||
step: 100,
|
|
||||||
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reqNationRice',
|
|
||||||
label: '국가 권장 쌀',
|
|
||||||
step: 100,
|
|
||||||
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '유저 전투장',
|
key: 'reqNationRice',
|
||||||
fields: [
|
label: '국가 권장 쌀',
|
||||||
{
|
step: 100,
|
||||||
key: 'reqHumanWarUrgentGold',
|
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
||||||
label: '긴급포상 금',
|
|
||||||
step: 100,
|
|
||||||
description:
|
|
||||||
'유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
|
|
||||||
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100 * 6)}) 가능한 금을 기준으로 하며, 현재 ${formatNumber(
|
|
||||||
data.value.zeroPolicy.reqHumanWarUrgentGold
|
|
||||||
)}입니다.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reqHumanWarUrgentRice',
|
|
||||||
label: '긴급포상 쌀',
|
|
||||||
step: 100,
|
|
||||||
description:
|
|
||||||
'유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
|
|
||||||
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100 * 6)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
|
|
||||||
data.value.zeroPolicy.reqHumanWarUrgentRice
|
|
||||||
)}입니다.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reqHumanWarRecommandGold',
|
|
||||||
label: '권장 금',
|
|
||||||
step: 100,
|
|
||||||
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
|
||||||
hint: `0이면 긴급포상 금의 2배를 기준으로 하며, 현재 ${formatNumber(
|
|
||||||
calcPolicyValue('reqHumanWarUrgentGold') * 2
|
|
||||||
)}입니다.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reqHumanWarRecommandRice',
|
|
||||||
label: '권장 쌀',
|
|
||||||
step: 100,
|
|
||||||
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
|
||||||
hint: `0이면 긴급포상 쌀의 2배를 기준으로 하며, 현재 ${formatNumber(
|
|
||||||
calcPolicyValue('reqHumanWarUrgentRice') * 2
|
|
||||||
)}입니다.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '유저 내정장',
|
key: 'reqHumanWarUrgentGold',
|
||||||
fields: [
|
label: '유저전투장 긴급포상 금',
|
||||||
{
|
step: 100,
|
||||||
key: 'reqHumanDevelGold',
|
description: '유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
|
||||||
label: '권장 금',
|
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100)} * 6) 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentGold)}입니다.`,
|
||||||
step: 100,
|
|
||||||
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reqHumanDevelRice',
|
|
||||||
label: '권장 쌀',
|
|
||||||
step: 100,
|
|
||||||
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'NPC 전투장',
|
key: 'reqHumanWarUrgentRice',
|
||||||
fields: [
|
label: '유저전투장 긴급포상 쌀',
|
||||||
{
|
step: 100,
|
||||||
key: 'reqNPCWarGold',
|
description: '유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
|
||||||
label: '권장 금',
|
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100)} * 6명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentRice)}입니다.`,
|
||||||
step: 100,
|
|
||||||
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
|
||||||
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100 * 4)}) 징병비를 기준으로 하며, 현재 ${formatNumber(
|
|
||||||
data.value.zeroPolicy.reqNPCWarGold
|
|
||||||
)}입니다.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reqNPCWarRice',
|
|
||||||
label: '권장 쌀',
|
|
||||||
step: 100,
|
|
||||||
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
|
||||||
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100 * 4)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
|
|
||||||
data.value.zeroPolicy.reqNPCWarRice
|
|
||||||
)}입니다.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'NPC 내정장',
|
key: 'reqHumanWarRecommandGold',
|
||||||
fields: [
|
label: '유저전투장 권장 금',
|
||||||
{
|
step: 100,
|
||||||
key: 'reqNPCDevelGold',
|
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||||
label: '권장 금',
|
hint: `0이면 유저전투장 긴급포상 금의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentGold') * 2)}입니다.`,
|
||||||
step: 100,
|
|
||||||
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
|
|
||||||
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 현재 ${formatNumber(
|
|
||||||
data.value.zeroPolicy.reqNPCDevelGold
|
|
||||||
)}입니다.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reqNPCDevelRice',
|
|
||||||
label: '권장 쌀',
|
|
||||||
step: 100,
|
|
||||||
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '자원 정책',
|
key: 'reqHumanWarRecommandRice',
|
||||||
fields: [
|
label: '유저전투장 권장 쌀',
|
||||||
{
|
step: 100,
|
||||||
key: 'minimumResourceActionAmount',
|
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||||
label: '포상/몰수/헌납 최소 단위',
|
hint: `0이면 유저전투장 긴급포상 쌀의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentRice') * 2)}입니다.`,
|
||||||
step: 100,
|
|
||||||
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maximumResourceActionAmount',
|
|
||||||
label: '포상/몰수/헌납 최대 단위',
|
|
||||||
step: 100,
|
|
||||||
description: '연산결과가 이 단위보다 크다면 이 값에 맞춥니다.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '전투/징병 기준',
|
key: 'reqHumanDevelGold',
|
||||||
fields: [
|
label: '유저내정장 권장 금',
|
||||||
{
|
step: 100,
|
||||||
key: 'minWarCrew',
|
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||||
label: '최소 전투 가능 병력 수',
|
|
||||||
step: 50,
|
|
||||||
description: '이보다 적을 때에는 징병을 시도합니다.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'minNPCRecruitCityPopulation',
|
|
||||||
label: 'NPC 최소 징병 가능 인구 수',
|
|
||||||
step: 100,
|
|
||||||
description:
|
|
||||||
'도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'safeRecruitCityPopulationRatio',
|
|
||||||
label: '제자리 징병 허용 인구율(%)',
|
|
||||||
step: 0.5,
|
|
||||||
description:
|
|
||||||
'전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
|
|
||||||
percent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'minNPCWarLeadership',
|
|
||||||
label: 'NPC 전투 참여 통솔 기준',
|
|
||||||
step: 5,
|
|
||||||
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '상태 기준',
|
key: 'reqHumanDevelRice',
|
||||||
fields: [
|
label: '유저내정장 권장 쌀',
|
||||||
{
|
step: 100,
|
||||||
key: 'properWarTrainAtmos',
|
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||||
label: '훈련/사기진작 목표치',
|
},
|
||||||
step: 5,
|
{
|
||||||
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
|
key: 'reqNPCWarGold',
|
||||||
},
|
label: 'NPC전투장 권장 금',
|
||||||
{
|
step: 100,
|
||||||
key: 'cureThreshold',
|
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||||
label: '요양 기준(%)',
|
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100)} * 4) 징병비를 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarGold)}입니다.`,
|
||||||
step: 5,
|
},
|
||||||
description: '요양 기준입니다. 이보다 많이 부상을 입으면 요양합니다.',
|
{
|
||||||
},
|
key: 'reqNPCWarRice',
|
||||||
],
|
label: 'NPC전투장 권장 쌀',
|
||||||
|
step: 100,
|
||||||
|
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||||
|
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100)} * 4명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarRice)}입니다.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reqNPCDevelGold',
|
||||||
|
label: 'NPC내정장 권장 금',
|
||||||
|
step: 100,
|
||||||
|
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
|
||||||
|
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCDevelGold)}입니다.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reqNPCDevelRice',
|
||||||
|
label: 'NPC내정장 권장 쌀',
|
||||||
|
step: 100,
|
||||||
|
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minimumResourceActionAmount',
|
||||||
|
label: '포상/몰수/헌납/삼/팜 최소 단위',
|
||||||
|
step: 100,
|
||||||
|
min: 100,
|
||||||
|
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maximumResourceActionAmount',
|
||||||
|
label: '포상/몰수/헌납/삼/팜 최대 단위',
|
||||||
|
step: 100,
|
||||||
|
min: 100,
|
||||||
|
description: '연산결과가 이 단위보다 크다면, 이 값에 맞춥니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minWarCrew',
|
||||||
|
label: '최소 전투 가능 병력 수',
|
||||||
|
step: 50,
|
||||||
|
description: '이보다 적을 때에는 징병을 시도합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minNPCRecruitCityPopulation',
|
||||||
|
label: 'NPC 최소 징병 가능 인구 수',
|
||||||
|
step: 100,
|
||||||
|
description: '도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
|
||||||
|
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'safeRecruitCityPopulationRatio',
|
||||||
|
label: '제자리 징병 허용 인구율(%)',
|
||||||
|
step: 0.5,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
percent: true,
|
||||||
|
description: '전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
|
||||||
|
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minNPCWarLeadership',
|
||||||
|
label: 'NPC 전투 참여 통솔 기준',
|
||||||
|
step: 5,
|
||||||
|
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'properWarTrainAtmos',
|
||||||
|
label: '훈련/사기진작 목표치',
|
||||||
|
step: 5,
|
||||||
|
min: 20,
|
||||||
|
max: 100,
|
||||||
|
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cureThreshold',
|
||||||
|
label: '요양 기준',
|
||||||
|
step: 5,
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
description: '요양 기준 %입니다. 이보다 많이 부상을 입으면 요양합니다.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const canEdit = computed(() => (data.value?.permissionLevel ?? 0) >= 3);
|
const priorityPanels = computed<PriorityPanel[]>(() => {
|
||||||
|
if (!data.value || !nationPriority.value || !generalPriority.value) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'nation',
|
||||||
|
title: 'NPC 사령턴 우선순위',
|
||||||
|
description: ['예턴이 없거나, 지정되어 있더라도 실패하면', '아래 순위에 따라 사령턴을 시도합니다.'],
|
||||||
|
setter: data.value.lastSetters.nation,
|
||||||
|
state: nationPriority.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'general',
|
||||||
|
title: 'NPC 일반턴 우선순위',
|
||||||
|
description: [
|
||||||
|
'순위가 높은 것부터 시도합니다.',
|
||||||
|
'아무것도 실행할 수 없으면 물자조달이나 인재탐색을 합니다.',
|
||||||
|
],
|
||||||
|
setter: data.value.lastSetters.general,
|
||||||
|
state: generalPriority.value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const resetPolicy = () => {
|
const resetPolicy = () => {
|
||||||
if (!data.value) {
|
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
|
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
|
||||||
|
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const rollbackPolicy = () => {
|
const rollbackPolicy = () => {
|
||||||
if (!lastSavedPolicy.value) {
|
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
policyDraft.value = clonePolicy(lastSavedPolicy.value);
|
policyDraft.value = clonePolicy(lastSavedPolicy.value);
|
||||||
|
notice.value = '이전 설정으로 되돌렸습니다.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitPolicy = async () => {
|
const submitPolicy = async () => {
|
||||||
if (!policyDraft.value) {
|
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
|
||||||
return;
|
error.value = null;
|
||||||
}
|
notice.value = null;
|
||||||
if (!window.confirm('저장할까요?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
|
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
|
||||||
await loadPolicy();
|
lastSavedPolicy.value = clonePolicy(policyDraft.value);
|
||||||
} catch (err) {
|
notice.value = 'NPC 정책이 반영되었습니다.';
|
||||||
error.value = resolveErrorMessage(err);
|
} catch (caught) {
|
||||||
|
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveItem = (list: string[], from: number, to: number) => {
|
|
||||||
if (to < 0 || to >= list.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [item] = list.splice(from, 1);
|
|
||||||
list.splice(to, 0, item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertByOrder = (list: string[], item: string, orderMap: Map<string, number>) => {
|
|
||||||
const targetOrder = orderMap.get(item) ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const index = list.findIndex((entry) => (orderMap.get(entry) ?? Number.MAX_SAFE_INTEGER) > targetOrder);
|
|
||||||
if (index === -1) {
|
|
||||||
list.push(item);
|
|
||||||
} else {
|
|
||||||
list.splice(index, 0, item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePriority = (section: PrioritySectionKey, item: string, enable: boolean) => {
|
|
||||||
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
|
|
||||||
if (!target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (enable) {
|
|
||||||
const index = target.inactive.indexOf(item);
|
|
||||||
if (index >= 0) {
|
|
||||||
target.inactive.splice(index, 1);
|
|
||||||
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
|
|
||||||
insertByOrder(target.active, item, orderMap);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = target.active.indexOf(item);
|
|
||||||
if (index >= 0) {
|
|
||||||
target.active.splice(index, 1);
|
|
||||||
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
|
|
||||||
insertByOrder(target.inactive, item, orderMap);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reorderPriority = (section: PrioritySectionKey, index: number, direction: number) => {
|
|
||||||
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
|
|
||||||
if (!target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
moveItem(target.active, index, index + direction);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetPriority = (section: PrioritySectionKey) => {
|
const resetPriority = (section: PrioritySectionKey) => {
|
||||||
if (!data.value) {
|
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (section === 'nation') {
|
if (section === 'nation') {
|
||||||
nationPriority.value = assignPriorityState(data.value.defaultNationPriority, data.value.availableNationPriorityItems);
|
nationPriority.value = assignPriorityState(
|
||||||
|
data.value.defaultNationPriority,
|
||||||
|
data.value.availableNationPriorityItems
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
generalPriority.value = assignPriorityState(
|
generalPriority.value = assignPriorityState(
|
||||||
data.value.defaultGeneralActionPriority,
|
data.value.defaultGeneralActionPriority,
|
||||||
data.value.availableGeneralActionPriorityItems
|
data.value.availableGeneralActionPriorityItems
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const rollbackPriority = (section: PrioritySectionKey) => {
|
const rollbackPriority = (section: PrioritySectionKey) => {
|
||||||
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
|
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
|
||||||
return;
|
if (section === 'nation') {
|
||||||
}
|
nationPriority.value = assignPriorityState(
|
||||||
if (section === 'nation' && data.value) {
|
lastSavedNationPriority.value,
|
||||||
nationPriority.value = assignPriorityState(lastSavedNationPriority.value, data.value.availableNationPriorityItems);
|
data.value.availableNationPriorityItems
|
||||||
}
|
);
|
||||||
if (section === 'general' && data.value) {
|
} else {
|
||||||
generalPriority.value = assignPriorityState(
|
generalPriority.value = assignPriorityState(
|
||||||
lastSavedGeneralPriority.value,
|
lastSavedGeneralPriority.value,
|
||||||
data.value.availableGeneralActionPriorityItems
|
data.value.availableGeneralActionPriorityItems
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
notice.value = '이전 설정으로 되돌렸습니다.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitPriority = async (section: PrioritySectionKey) => {
|
const submitPriority = async (section: PrioritySectionKey) => {
|
||||||
if (!data.value) {
|
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||||
return;
|
if (!state || !window.confirm('저장할까요?')) return;
|
||||||
}
|
error.value = null;
|
||||||
if (!window.confirm('저장할까요?')) {
|
notice.value = null;
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
if (section === 'nation' && nationPriority.value) {
|
if (section === 'nation') {
|
||||||
await trpc.npc.setNationPriority.mutate(nationPriority.value.active);
|
await trpc.npc.setNationPriority.mutate(state.active);
|
||||||
|
lastSavedNationPriority.value = [...state.active];
|
||||||
|
} else {
|
||||||
|
await trpc.npc.setGeneralPriority.mutate(state.active);
|
||||||
|
lastSavedGeneralPriority.value = [...state.active];
|
||||||
}
|
}
|
||||||
if (section === 'general' && generalPriority.value) {
|
notice.value = 'NPC 정책이 반영되었습니다.';
|
||||||
await trpc.npc.setGeneralPriority.mutate(generalPriority.value.active);
|
} catch (caught) {
|
||||||
}
|
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
|
||||||
await loadPolicy();
|
|
||||||
} catch (err) {
|
|
||||||
error.value = resolveErrorMessage(err);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
|
||||||
|
dragState.value = { section, bucket, index };
|
||||||
|
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const source = dragState.value;
|
||||||
|
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||||
|
if (!source || source.section !== section || !state) return;
|
||||||
|
const sourceList = state[source.bucket];
|
||||||
|
const targetList = state[bucket];
|
||||||
|
const [item] = sourceList.splice(source.index, 1);
|
||||||
|
if (!item) return;
|
||||||
|
let index = targetIndex ?? targetList.length;
|
||||||
|
if (sourceList === targetList && source.index < index) index -= 1;
|
||||||
|
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
|
||||||
|
dragState.value = null;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="npc-page">
|
<main id="npc-policy-page" class="npc-page">
|
||||||
<header class="page-header">
|
<nav class="top-back-bar legacy-bg0">
|
||||||
<div>
|
<RouterLink class="back-button" to="/">돌아가기</RouterLink>
|
||||||
<h1 class="page-title">NPC 정책</h1>
|
<strong>NPC 정책</strong>
|
||||||
<p class="page-subtitle">사령/일반 AI 우선순위 및 자원 기준</p>
|
</nav>
|
||||||
|
|
||||||
|
<div v-if="loading && !data" class="page-state legacy-bg0">불러오는 중...</div>
|
||||||
|
<div v-else-if="!data" class="page-state error-state legacy-bg0" role="alert">
|
||||||
|
{{ error ?? 'NPC 정책을 불러오지 못했습니다.' }}
|
||||||
|
<button type="button" @click="loadPolicy">다시 시도</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-else-if="policyDraft" id="container" class="policy-container legacy-bg0">
|
||||||
|
<div class="section_bar legacy-bg1">국가 정책</div>
|
||||||
|
<div class="setter">
|
||||||
|
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
|
||||||
|
data.lastSetters.policy.date ?? '설정 기록 없음'
|
||||||
|
}})
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
|
||||||
<RouterLink class="ghost" to="/">메인</RouterLink>
|
<div v-if="error" class="feedback error-feedback" role="alert">{{ error }}</div>
|
||||||
<button class="ghost" @click="loadPolicy">새로고침</button>
|
<div v-if="notice" class="feedback notice-feedback" role="status">{{ notice }}</div>
|
||||||
|
|
||||||
|
<div class="form_list">
|
||||||
|
<div v-for="field in policyFields" :key="field.key" class="policy-field">
|
||||||
|
<div class="field-row">
|
||||||
|
<label :for="`npc-policy-${field.key}`">{{ field.label }}</label>
|
||||||
|
<input
|
||||||
|
v-if="field.percent"
|
||||||
|
:id="`npc-policy-${field.key}`"
|
||||||
|
v-model.number="safeRecruitPercent"
|
||||||
|
type="number"
|
||||||
|
:step="field.step"
|
||||||
|
:min="field.min"
|
||||||
|
:max="field.max"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
:id="`npc-policy-${field.key}`"
|
||||||
|
v-model.number="policyDraft[field.key]"
|
||||||
|
type="number"
|
||||||
|
:step="field.step"
|
||||||
|
:min="field.min"
|
||||||
|
:max="field.max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>{{ field.description }}</p>
|
||||||
|
<p v-if="field.hint">{{ field.hint }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div class="work-in-progress">
|
||||||
|
전투 부대는 작업중입니다(json양식: {부대번호:[시작도시번호(아국),도착도시번호(적국)],...})
|
||||||
|
<br />후방 징병 부대는 작업중입니다(json양식: [부대번호,...]) <br />내정 부대는 작업중입니다(json양식:
|
||||||
|
[부대번호,...])
|
||||||
|
<input type="hidden" :value="JSON.stringify(policyDraft.CombatForce)" />
|
||||||
|
<input type="hidden" :value="JSON.stringify(policyDraft.SupportForce)" />
|
||||||
|
<input type="hidden" :value="JSON.stringify(policyDraft.DevelopForce)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<section v-if="loading && !data">
|
<div class="control_bar">
|
||||||
<PanelCard title="NPC 정책 로딩">
|
<div class="button-group">
|
||||||
<SkeletonLines :lines="6" />
|
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
|
||||||
</PanelCard>
|
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="data && policyDraft" class="npc-layout">
|
|
||||||
<PanelCard title="국가 정책" subtitle="NPC 자원 기준과 전투 판단 기준">
|
|
||||||
<div class="setter">
|
|
||||||
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
|
|
||||||
data.lastSetters.policy.date ?? '설정 기록 없음'
|
|
||||||
}})
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
|
<button class="submit_btn" type="button" @click="submitPolicy">설정</button>
|
||||||
<div v-for="section in policySections" :key="section.title" class="policy-section">
|
</div>
|
||||||
<h3 class="section-title">{{ section.title }}</h3>
|
|
||||||
<div class="policy-grid">
|
|
||||||
<div
|
|
||||||
v-for="field in section.fields"
|
|
||||||
:key="field.key"
|
|
||||||
class="policy-field"
|
|
||||||
>
|
|
||||||
<label class="field-label">{{ field.label }}</label>
|
|
||||||
<input
|
|
||||||
v-if="field.percent"
|
|
||||||
v-model.number="safeRecruitPercent"
|
|
||||||
type="number"
|
|
||||||
class="field-input"
|
|
||||||
:step="field.step"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
:disabled="!canEdit"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
v-model.number="policyDraft[field.key as PolicyKey]"
|
|
||||||
type="number"
|
|
||||||
class="field-input"
|
|
||||||
:step="field.step"
|
|
||||||
min="0"
|
|
||||||
:disabled="!canEdit"
|
|
||||||
/>
|
|
||||||
<p class="field-desc">{{ field.description }}</p>
|
|
||||||
<p v-if="field.hint" class="field-hint">{{ field.hint }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control-bar">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="resetPolicy">초깃값으로</button>
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="rollbackPolicy">이전값으로</button>
|
|
||||||
</div>
|
|
||||||
<button class="primary" :disabled="!canEdit" @click="submitPolicy">설정</button>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<div class="priority-grid">
|
<div class="priority-sections">
|
||||||
<PanelCard title="NPC 사령턴 우선순위" subtitle="예턴 실패 시 우선순위대로 실행">
|
<section
|
||||||
<div class="setter">
|
v-for="panel in priorityPanels"
|
||||||
최근 설정: {{ data.lastSetters.nation.setter ?? '-없음-' }} ({{
|
:key="panel.key"
|
||||||
data.lastSetters.nation.date ?? '설정 기록 없음'
|
:class="['priority-panel', panel.key === 'nation' ? 'half_section_left' : 'half_section_right']"
|
||||||
}})
|
>
|
||||||
|
<div class="section_bar legacy-bg1">{{ panel.title }}</div>
|
||||||
|
<div class="priority-meta">
|
||||||
|
<small>
|
||||||
|
최근 설정: {{ panel.setter.setter ?? '-없음-' }} ({{
|
||||||
|
panel.setter.date ?? '설정 기록 없음'
|
||||||
|
}})
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="priority-description">
|
||||||
|
<small>{{ panel.description[0] }}<br />{{ panel.description[1] }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
|
|
||||||
<div class="priority-columns">
|
<div class="priority-columns">
|
||||||
<div class="priority-column">
|
<div class="priority-column">
|
||||||
<div class="column-title">비활성</div>
|
<div class="sub_bar legacy-bg2">비활성</div>
|
||||||
<div class="priority-list">
|
<div
|
||||||
|
class="priority-list"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="dropPriority($event, panel.key, 'inactive')"
|
||||||
|
>
|
||||||
|
<div class="inactive-header"><비활성화 항목들></div>
|
||||||
<div
|
<div
|
||||||
v-for="item in nationPriority?.inactive ?? []"
|
v-for="(item, index) in panel.state.inactive"
|
||||||
:key="item"
|
:key="item"
|
||||||
class="priority-item"
|
class="priority-item"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="startDrag($event, panel.key, 'inactive', index)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
|
||||||
>
|
>
|
||||||
<span class="priority-name">{{ item }}</span>
|
<div class="priority_info">
|
||||||
<span
|
<span class="drag-handle">≡</span>
|
||||||
class="priority-help"
|
<span>{{ item }}</span>
|
||||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
<button
|
||||||
>
|
class="help-button"
|
||||||
?
|
type="button"
|
||||||
</span>
|
:aria-label="`${item} 설명`"
|
||||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, true)">
|
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||||
활성
|
>
|
||||||
</button>
|
?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="priority-column">
|
<div class="priority-column">
|
||||||
<div class="column-title">활성</div>
|
<div class="sub_bar legacy-bg2">활성</div>
|
||||||
<div class="priority-list">
|
<div
|
||||||
|
class="priority-list"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="dropPriority($event, panel.key, 'active')"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(item, idx) in nationPriority?.active ?? []"
|
v-for="(item, index) in panel.state.active"
|
||||||
:key="item"
|
:key="`${item}-${index}`"
|
||||||
class="priority-item"
|
class="priority-item"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="startDrag($event, panel.key, 'active', index)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.stop="dropPriority($event, panel.key, 'active', index)"
|
||||||
>
|
>
|
||||||
<div class="priority-main">
|
<div class="priority_info">
|
||||||
<span class="priority-name">{{ item }}</span>
|
<span class="drag-handle">≡</span>
|
||||||
<span
|
<span>{{ item }}</span>
|
||||||
class="priority-help"
|
<button
|
||||||
|
class="help-button"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`${item} 설명`"
|
||||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="priority-actions">
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, -1)">위</button>
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, 1)">아래</button>
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, false)">
|
|
||||||
비활성
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-bar">
|
<div class="control_bar priority-control">
|
||||||
<div class="btn-group">
|
<div class="button-group">
|
||||||
<button class="ghost" :disabled="!canEdit" @click="resetPriority('nation')">초깃값으로</button>
|
<button class="reset_btn" type="button" @click="resetPriority(panel.key)">
|
||||||
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('nation')">이전값으로</button>
|
초깃값으로
|
||||||
|
</button>
|
||||||
|
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
|
||||||
|
이전값으로
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary" :disabled="!canEdit" @click="submitPriority('nation')">설정</button>
|
<button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</section>
|
||||||
|
|
||||||
<PanelCard title="NPC 일반턴 우선순위" subtitle="순위가 높은 것부터 시도">
|
|
||||||
<div class="setter">
|
|
||||||
최근 설정: {{ data.lastSetters.general.setter ?? '-없음-' }} ({{
|
|
||||||
data.lastSetters.general.date ?? '설정 기록 없음'
|
|
||||||
}})
|
|
||||||
</div>
|
|
||||||
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
|
|
||||||
<div class="priority-columns">
|
|
||||||
<div class="priority-column">
|
|
||||||
<div class="column-title">비활성</div>
|
|
||||||
<div class="priority-list">
|
|
||||||
<div
|
|
||||||
v-for="item in generalPriority?.inactive ?? []"
|
|
||||||
:key="item"
|
|
||||||
class="priority-item"
|
|
||||||
>
|
|
||||||
<span class="priority-name">{{ item }}</span>
|
|
||||||
<span
|
|
||||||
class="priority-help"
|
|
||||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, true)">
|
|
||||||
활성
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="priority-column">
|
|
||||||
<div class="column-title">활성</div>
|
|
||||||
<div class="priority-list">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in generalPriority?.active ?? []"
|
|
||||||
:key="item"
|
|
||||||
class="priority-item"
|
|
||||||
>
|
|
||||||
<div class="priority-main">
|
|
||||||
<span class="priority-name">{{ item }}</span>
|
|
||||||
<span
|
|
||||||
class="priority-help"
|
|
||||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="priority-actions">
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, -1)">위</button>
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, 1)">아래</button>
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, false)">
|
|
||||||
비활성
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control-bar">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="resetPriority('general')">초깃값으로</button>
|
|
||||||
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('general')">이전값으로</button>
|
|
||||||
</div>
|
|
||||||
<button class="primary" :disabled="!canEdit" @click="submitPriority('general')">설정</button>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:global(html),
|
||||||
|
:global(body),
|
||||||
|
:global(#app) {
|
||||||
|
min-width: 500px;
|
||||||
|
margin: 0;
|
||||||
|
background: #000;
|
||||||
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body:has(#npc-policy-page)) {
|
||||||
|
min-width: 500px;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
.npc-page {
|
.npc-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
color: #fff;
|
||||||
display: flex;
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
flex-direction: column;
|
font-size: 14px;
|
||||||
gap: 16px;
|
line-height: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.legacy-bg0 {
|
||||||
display: flex;
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.legacy-bg1 {
|
||||||
font-size: 1.6rem;
|
background-image: url('/image/game/back_green.jpg');
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.legacy-bg2 {
|
||||||
font-size: 0.85rem;
|
background-image: url('/image/game/back_blue.jpg');
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.top-back-bar {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-wrap: wrap;
|
height: 32px;
|
||||||
gap: 8px;
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.top-back-bar strong {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
font-size: 24px;
|
||||||
padding: 6px 12px;
|
line-height: 32px;
|
||||||
font-size: 0.8rem;
|
font-weight: 400;
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary {
|
.back-button {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.6);
|
position: absolute;
|
||||||
padding: 6px 12px;
|
inset: 0 auto 0 0;
|
||||||
font-size: 0.8rem;
|
width: 88px;
|
||||||
cursor: pointer;
|
color: #fff;
|
||||||
background: rgba(201, 164, 90, 0.2);
|
background: #087f45;
|
||||||
color: inherit;
|
border: 1px solid #0a9960;
|
||||||
|
border-radius: 0 0 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 30px;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.back-button:hover,
|
||||||
color: #f5b7b1;
|
.back-button:focus-visible {
|
||||||
font-size: 0.85rem;
|
background: #0a9960;
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npc-layout {
|
.policy-container,
|
||||||
display: flex;
|
.page-state {
|
||||||
flex-direction: column;
|
width: 100%;
|
||||||
gap: 16px;
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid #888;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setter {
|
.page-state {
|
||||||
font-size: 0.75rem;
|
padding: 16px;
|
||||||
color: rgba(232, 221, 196, 0.6);
|
min-height: 100px;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.readonly-note {
|
.page-state button {
|
||||||
font-size: 0.75rem;
|
margin-left: 12px;
|
||||||
color: rgba(245, 208, 138, 0.8);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.policy-section {
|
.section_bar {
|
||||||
margin-top: 12px;
|
min-height: 23px;
|
||||||
|
border: 0.5px solid #aaa;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.setter,
|
||||||
font-size: 0.95rem;
|
.priority-meta {
|
||||||
margin-bottom: 8px;
|
min-height: 21px;
|
||||||
color: rgba(232, 221, 196, 0.9);
|
padding: 0 12px;
|
||||||
|
color: #8e8e8e;
|
||||||
|
font-size: 12.25px;
|
||||||
|
line-height: 18.375px;
|
||||||
|
text-align: right;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.policy-grid {
|
.feedback {
|
||||||
|
margin: 4px 12px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-feedback,
|
||||||
|
.error-state {
|
||||||
|
color: #ffd4d4;
|
||||||
|
border-color: #a94442;
|
||||||
|
background-color: rgba(120, 20, 20, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-feedback {
|
||||||
|
color: #d9ffd9;
|
||||||
|
border-color: #3c763d;
|
||||||
|
background-color: rgba(20, 90, 20, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form_list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
margin: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.policy-field {
|
.policy-field {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
min-width: 0;
|
||||||
background: rgba(16, 16, 16, 0.5);
|
padding: 0 10.5px;
|
||||||
padding: 10px;
|
box-sizing: border-box;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-row {
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-input {
|
|
||||||
background: rgba(12, 12, 12, 0.8);
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
|
||||||
color: inherit;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-desc {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: rgba(232, 221, 196, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: rgba(232, 221, 196, 0.45);
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.priority-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
grid-template-columns: minmax(0, 1fr) 224px;
|
||||||
gap: 16px;
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row input {
|
||||||
|
width: 224px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 5.25px 10.5px;
|
||||||
|
color: #303030;
|
||||||
|
background: #ddd;
|
||||||
|
border: 1px solid #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row input:focus {
|
||||||
|
border-color: #66afe9;
|
||||||
|
outline: 2px solid rgba(102, 175, 233, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-field p {
|
||||||
|
min-height: 18.375px;
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 12.25px;
|
||||||
|
line-height: 18.375px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-in-progress {
|
||||||
|
margin: 0 11px 15px;
|
||||||
|
padding: 14px;
|
||||||
|
color: #fff;
|
||||||
|
background: #444;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control_bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 10.6667px 10.6667px;
|
||||||
|
min-height: 56.8125px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control_bar button {
|
||||||
|
width: 150px;
|
||||||
|
height: 35.5px;
|
||||||
|
margin-top: 10.6667px;
|
||||||
|
padding: 5.25px 10.5px;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset_btn {
|
||||||
|
background: #303030;
|
||||||
|
border-color: #2b2b2b !important;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revert_btn {
|
||||||
|
background: #444;
|
||||||
|
border-color: #3d3d3d !important;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit_btn {
|
||||||
|
margin-left: 14px;
|
||||||
|
background: #375a7f;
|
||||||
|
border-color: #325172 !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control_bar button:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control_bar button:focus-visible,
|
||||||
|
.help-button:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-sections {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.half_section_left {
|
||||||
|
border-right: 0.5px solid #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-meta {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-description {
|
||||||
|
clear: both;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #888;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-description small {
|
||||||
|
font-size: 12.25px;
|
||||||
|
line-height: 18.375px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-columns {
|
.priority-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-column {
|
.priority-column {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
min-width: 0;
|
||||||
padding: 8px;
|
|
||||||
background: rgba(12, 12, 12, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-title {
|
.sub_bar {
|
||||||
font-size: 0.8rem;
|
height: 22px;
|
||||||
margin-bottom: 6px;
|
margin: 0 5px;
|
||||||
color: rgba(232, 221, 196, 0.7);
|
border: 0.5px solid #aaa;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-list {
|
.priority-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 37px;
|
||||||
|
margin: 0 10px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
}
|
||||||
|
|
||||||
|
.inactive-header,
|
||||||
|
.priority-item {
|
||||||
|
height: 37px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-header {
|
||||||
|
color: #1d1d1d;
|
||||||
|
background: #d6d6d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-item {
|
.priority-item {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
color: #fff;
|
||||||
padding: 6px;
|
background: #303030;
|
||||||
display: flex;
|
cursor: grab;
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-main {
|
.priority-item:active {
|
||||||
display: flex;
|
cursor: grabbing;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority_info {
|
||||||
|
display: grid;
|
||||||
|
height: 21px;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr) 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-name {
|
.drag-handle {
|
||||||
font-size: 0.78rem;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-actions {
|
.help-button {
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.priority-help {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
width: 24px;
|
||||||
align-items: center;
|
height: 22.375px;
|
||||||
justify-content: center;
|
padding: 0 3.5px;
|
||||||
width: 18px;
|
color: #fff;
|
||||||
height: 18px;
|
background: #444;
|
||||||
font-size: 0.7rem;
|
border: 1px solid #3d3d3d;
|
||||||
border-radius: 999px;
|
border-radius: 3px;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
font-size: 12.25px;
|
||||||
cursor: default;
|
line-height: 18.375px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-help::after {
|
.help-button::after {
|
||||||
content: attr(data-text);
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 125%;
|
right: 0;
|
||||||
left: 50%;
|
bottom: calc(100% + 5px);
|
||||||
transform: translateX(-50%);
|
z-index: 10;
|
||||||
background: rgba(16, 16, 16, 0.9);
|
width: 300px;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
padding: 7px;
|
||||||
padding: 8px;
|
color: #fff;
|
||||||
font-size: 0.7rem;
|
background: #111;
|
||||||
width: 220px;
|
border: 1px solid #777;
|
||||||
|
border-radius: 4px;
|
||||||
|
content: attr(data-text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.15s ease;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-help:hover::after {
|
.help-button:hover::after,
|
||||||
|
.help-button:focus-visible::after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
.priority-control {
|
||||||
.npc-page {
|
margin-top: 0;
|
||||||
padding: 16px;
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.form_list,
|
||||||
|
.priority-sections {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.half_section_left {
|
||||||
|
border-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ onMounted(() => {
|
|||||||
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
|
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
|
||||||
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
|
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
|
||||||
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
||||||
|
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
|
||||||
<button class="ghost" @click="refreshPublicData">새로고침</button>
|
<button class="ghost" @click="refreshPublicData">새로고침</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -817,6 +817,7 @@ export const adminRouter = router({
|
|||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
korName: string;
|
korName: string;
|
||||||
@@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
|
|
||||||
private mapProfile(
|
private mapProfile(
|
||||||
row: GatewayProfileRecord,
|
row: GatewayProfileRecord,
|
||||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
|
runtimeMap: Map<
|
||||||
|
string,
|
||||||
|
{ apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean }
|
||||||
|
>
|
||||||
): LobbyProfileStatus {
|
): LobbyProfileStatus {
|
||||||
const meta = row.meta;
|
const meta = row.meta;
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
runtime: runtimeMap.get(row.profileName) ?? {
|
runtime: runtimeMap.get(row.profileName) ?? {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
|
|||||||
export interface ProfileRuntimeState {
|
export interface ProfileRuntimeState {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +67,19 @@ export const planProfileReconcile = (
|
|||||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||||
return {
|
return {
|
||||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
|
shouldStart: !(
|
||||||
|
runtime.apiRunning &&
|
||||||
|
runtime.daemonRunning &&
|
||||||
|
runtime.battleSimRunning &&
|
||||||
|
runtime.tournamentRunning
|
||||||
|
),
|
||||||
shouldStop: false,
|
shouldStop: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
shouldStart: false,
|
shouldStart: false,
|
||||||
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
|
shouldStop:
|
||||||
|
runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,8 +280,16 @@ const parseInstallOptions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
`sammo:${profileName}:${
|
||||||
|
role === 'api'
|
||||||
|
? 'game-api'
|
||||||
|
: role === 'daemon'
|
||||||
|
? 'turn-daemon'
|
||||||
|
: role === 'battle-sim'
|
||||||
|
? 'battle-sim-worker'
|
||||||
|
: 'tournament-worker'
|
||||||
|
}`;
|
||||||
|
|
||||||
const isMissingProcessError = (error: unknown): boolean =>
|
const isMissingProcessError = (error: unknown): boolean =>
|
||||||
error instanceof Error && /process or namespace not found/i.test(error.message);
|
error instanceof Error && /process or namespace not found/i.test(error.message);
|
||||||
@@ -285,11 +300,13 @@ export const buildProcessDefinitions = (
|
|||||||
): {
|
): {
|
||||||
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
|
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
|
battleSim: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
} => {
|
} => {
|
||||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||||
@@ -327,6 +344,15 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: daemonCwd,
|
cwd: daemonCwd,
|
||||||
env: daemonEnv,
|
env: daemonEnv,
|
||||||
},
|
},
|
||||||
|
battleSim: {
|
||||||
|
name: battleSimName,
|
||||||
|
script: apiScript,
|
||||||
|
cwd: apiCwd,
|
||||||
|
env: {
|
||||||
|
...apiEnv,
|
||||||
|
GAME_API_ROLE: 'battle-sim-worker',
|
||||||
|
},
|
||||||
|
},
|
||||||
tournament: {
|
tournament: {
|
||||||
name: tournamentName,
|
name: tournamentName,
|
||||||
script: apiScript,
|
script: apiScript,
|
||||||
@@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
|||||||
profileNames.map((profileName) => {
|
profileNames.map((profileName) => {
|
||||||
const apiName = buildProcessName(profileName, 'api');
|
const apiName = buildProcessName(profileName, 'api');
|
||||||
const daemonName = buildProcessName(profileName, 'daemon');
|
const daemonName = buildProcessName(profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||||
return {
|
return {
|
||||||
profileName,
|
profileName,
|
||||||
apiRunning: processNames.get(apiName) ?? false,
|
apiRunning: processNames.get(apiName) ?? false,
|
||||||
daemonRunning: processNames.get(daemonName) ?? false,
|
daemonRunning: processNames.get(daemonName) ?? false,
|
||||||
|
battleSimRunning: processNames.get(battleSimName) ?? false,
|
||||||
tournamentRunning: processNames.get(tournamentName) ?? false,
|
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
try {
|
try {
|
||||||
await this.processManager.start(definitions.api);
|
await this.processManager.start(definitions.api);
|
||||||
await this.processManager.start(definitions.daemon);
|
await this.processManager.start(definitions.daemon);
|
||||||
|
await this.processManager.start(definitions.battleSim);
|
||||||
await this.processManager.start(definitions.tournament);
|
await this.processManager.start(definitions.tournament);
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return true;
|
return true;
|
||||||
@@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const name of [apiName, daemonName, tournamentName]) {
|
for (const name of [apiName, daemonName, battleSimName, tournamentName]) {
|
||||||
if (!existingNames.has(name)) {
|
if (!existingNames.has(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const createHarness = (
|
|||||||
? [
|
? [
|
||||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||||
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
||||||
|
{ name: 'sammo:che:2:battle-sim-worker', status: 'online' },
|
||||||
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -138,7 +139,7 @@ const createHarness = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('GatewayOrchestrator first-class operations', () => {
|
describe('GatewayOrchestrator first-class operations', () => {
|
||||||
it('starts both profile processes and records success', async () => {
|
it('starts every profile process and records success', async () => {
|
||||||
const harness = createHarness(buildOperation('START'));
|
const harness = createHarness(buildOperation('START'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops both profile processes and records success', async () => {
|
it('stops every profile process and records success', async () => {
|
||||||
const harness = createHarness(buildOperation('STOP'));
|
const harness = createHarness(buildOperation('STOP'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.completions).toEqual(['FAILED']);
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attempts to stop both roles before reporting a partial PM2 failure', async () => {
|
it('attempts to stop every role before reporting a partial PM2 failure', async () => {
|
||||||
const harness = createHarness(buildOperation('STOP'), false, true);
|
const harness = createHarness(buildOperation('STOP'), false, true);
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['FAILED']);
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -39,6 +40,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('PREOPEN', {
|
planProfileReconcile('PREOPEN', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -49,6 +51,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
battleSimRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
@@ -59,6 +62,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('STOPPED', {
|
planProfileReconcile('STOPPED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: true });
|
).toEqual({ shouldStart: false, shouldStop: true });
|
||||||
@@ -69,6 +73,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RESERVED', {
|
planProfileReconcile('RESERVED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
@@ -95,6 +100,11 @@ describe('buildProcessDefinitions', () => {
|
|||||||
});
|
});
|
||||||
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||||
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
||||||
|
expect(definitions.battleSim).toMatchObject({
|
||||||
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
|
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
||||||
|
env: { GAME_API_ROLE: 'battle-sim-worker' },
|
||||||
|
});
|
||||||
expect(definitions.tournament).toMatchObject({
|
expect(definitions.tournament).toMatchObject({
|
||||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
||||||
@@ -107,6 +117,7 @@ describe('buildProcessDefinitions', () => {
|
|||||||
|
|
||||||
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
||||||
|
expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
|
|||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
apiRunning: runtimeRunning,
|
apiRunning: runtimeRunning,
|
||||||
daemonRunning: runtimeRunning,
|
daemonRunning: runtimeRunning,
|
||||||
|
battleSimRunning: runtimeRunning,
|
||||||
tournamentRunning: runtimeRunning,
|
tournamentRunning: runtimeRunning,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type AdminProfile = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
buildCommitSha?: string;
|
buildCommitSha?: string;
|
||||||
@@ -1352,7 +1353,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||||
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
|
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM:
|
||||||
|
{{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
|
||||||
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ type Profile = {
|
|||||||
buildWorkspace?: string;
|
buildWorkspace?: string;
|
||||||
buildError?: string;
|
buildError?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
|
runtime: {
|
||||||
|
apiRunning: boolean;
|
||||||
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
|
tournamentRunning: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
@@ -377,6 +382,14 @@ onBeforeUnmount(() => {
|
|||||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
|
<div class="text-xs text-zinc-500">Battle sim worker</div>
|
||||||
|
<div
|
||||||
|
:class="selectedProfile.runtime.battleSimRunning ? 'text-emerald-400' : 'text-zinc-500'"
|
||||||
|
>
|
||||||
|
{{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
<div class="text-xs text-zinc-500">Tournament worker</div>
|
<div class="text-xs text-zinc-500">Tournament worker</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴
|
|||||||
`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기
|
`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기
|
||||||
초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을
|
초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을
|
||||||
ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다.
|
ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다.
|
||||||
|
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
||||||
|
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
||||||
|
비교한다.
|
||||||
|
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
|
||||||
|
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
|
||||||
|
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
|
||||||
|
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
|
||||||
|
않아 동적 호환 판정에서 제외한다.
|
||||||
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
|
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
|
||||||
기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로
|
기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로
|
||||||
올리지 않는다.
|
올리지 않는다.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Move items into the main docs once they are finalized.
|
|||||||
- [AI suggestion] Define gateway login handoff + profile selection flow for the game frontend (token delivery, auto-login, cookie vs localStorage policy).
|
- [AI suggestion] Define gateway login handoff + profile selection flow for the game frontend (token delivery, auto-login, cookie vs localStorage policy).
|
||||||
- [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰.
|
- [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰.
|
||||||
- [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록).
|
- [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록).
|
||||||
- [AI suggestion] Port legacy main UI components into `app/game-frontend` (MapViewer, CommandSelectForm, MessagePanel 등).
|
- [AI suggestion] Port remaining legacy main UI components into `app/game-frontend` (MessagePanel 이관 완료; 나머지 패널 추적).
|
||||||
- [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout.
|
- [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout.
|
||||||
- [AI suggestion] Implement join/빙의 UI and post-creation refresh flow.
|
- [AI suggestion] Implement join/빙의 UI and post-creation refresh flow.
|
||||||
- [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements.
|
- [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements.
|
||||||
@@ -57,7 +57,7 @@ Move items into the main docs once they are finalized.
|
|||||||
- [AI suggestion] Wire `realtimeEnabled` to an SSE or polling channel and update main dashboard data buckets (map/lobby/messages/commands).
|
- [AI suggestion] Wire `realtimeEnabled` to an SSE or polling channel and update main dashboard data buckets (map/lobby/messages/commands).
|
||||||
- [AI suggestion] Finalize static asset and web base URLs (`VITE_GAME_WEB_URL`, `VITE_GAME_ASSET_URL`) and document deployment mapping for legacy images.
|
- [AI suggestion] Finalize static asset and web base URLs (`VITE_GAME_WEB_URL`, `VITE_GAME_ASSET_URL`) and document deployment mapping for legacy images.
|
||||||
- [AI suggestion] Expand Join UI to cover inherit options (특기/도시/턴타임/보너스 스탯) using `join.getConfig` and `join.createGeneral` inputs.
|
- [AI suggestion] Expand Join UI to cover inherit options (특기/도시/턴타임/보너스 스탯) using `join.getConfig` and `join.createGeneral` inputs.
|
||||||
- [AI suggestion] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads.
|
- [x] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads.
|
||||||
- [AI suggestion] Port legacy TipTap-based editors (국가 방침/임관 권유) into game-frontend and reuse the new board image upload policy.
|
- [AI suggestion] Port legacy TipTap-based editors (국가 방침/임관 권유) into game-frontend and reuse the new board image upload policy.
|
||||||
|
|
||||||
## Runtime and Operations (Lower Priority)
|
## Runtime and Operations (Lower Priority)
|
||||||
|
|||||||
@@ -210,6 +210,17 @@ Three post-required cooldown cases project the legacy `next_execute` KV and
|
|||||||
core general meta into the same world-level cooldown record. They cover the
|
core general meta into the same world-level cooldown record. They cover the
|
||||||
stored `current + 60 - preReq` value, rejection one turn before availability,
|
stored `current + 60 - preReq` value, rejection one turn before availability,
|
||||||
and successful execution exactly at the boundary.
|
and successful execution exactly at the boundary.
|
||||||
|
Four missing-target cases cover nonexistent general IDs for gift and
|
||||||
|
employment plus nonexistent city IDs for spying and movement. Both engines
|
||||||
|
reject the requested command, execute rest without command RNG, and produce
|
||||||
|
the same semantic state delta.
|
||||||
|
Thirteen resource argument and balance cases cover 100-unit rounding and
|
||||||
|
minimum/maximum clamps for gift, donation, and rice trade; donation against
|
||||||
|
available and minimum resources; and gift reserve and self-target rejection.
|
||||||
|
They compare normalized last-turn arguments, RNG, fallback, and semantic state
|
||||||
|
deltas.
|
||||||
|
Requests that omit the required argument object remain unverified because the
|
||||||
|
reference runner did not terminate within the bounded comparison run.
|
||||||
This is not yet a claim that every command-specific
|
This is not yet a claim that every command-specific
|
||||||
constraint, clamp and persistence boundary has been dynamically
|
constraint, clamp and persistence boundary has been dynamically
|
||||||
compared.
|
compared.
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ tree instead of replacing images with layout-neutral placeholders.
|
|||||||
NPC list, including mutations and recoverable API failures.
|
NPC list, including mutations and recoverable API failures.
|
||||||
`tournament-betting.spec.ts` covers the separate tournament and tournament
|
`tournament-betting.spec.ts` covers the separate tournament and tournament
|
||||||
betting routes, including a recoverable failed bet.
|
betting routes, including a recoverable failed bet.
|
||||||
|
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
|
||||||
|
명예의 전당 computed DOM without embedding the reference password.
|
||||||
|
`battleSimulator.spec.ts` covers the authenticated simulator with and without
|
||||||
|
an owned general. `battleSimulatorRef.spec.ts` can additionally exercise the
|
||||||
|
live reference page when its URL, user, and ignored password file are supplied.
|
||||||
|
|
||||||
Run the suite from the core2026 repository root:
|
Run the suite from the core2026 repository root:
|
||||||
|
|
||||||
@@ -31,6 +36,16 @@ When another worktree occupies the default ports, set
|
|||||||
`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
|
`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
|
||||||
tournament and betting screenshots.
|
tournament and betting screenshots.
|
||||||
|
|
||||||
|
Run the focused simulator fixture with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter game-frontend test:e2e:battle-simulator
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional reference check uses `REF_BATTLE_SIM_URL`, `REF_USER_ID`, and
|
||||||
|
`REF_USER_PASSWORD_FILE`. The password is read inside the test and is never
|
||||||
|
written to screenshots or reports.
|
||||||
|
|
||||||
The suite starts both applications at their public prefixes:
|
The suite starts both applications at their public prefixes:
|
||||||
|
|
||||||
- gateway: `http://127.0.0.1:15100/gateway/`
|
- gateway: `http://127.0.0.1:15100/gateway/`
|
||||||
@@ -43,22 +58,27 @@ storage, route guards, and image loading.
|
|||||||
|
|
||||||
## Enforced contracts
|
## Enforced contracts
|
||||||
|
|
||||||
| Screen | Ref entry point | Current automated contract |
|
| Screen | Ref entry point | Current automated contract |
|
||||||
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||||
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
|
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
||||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
||||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
||||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
||||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||||
|
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||||
|
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, fixed-seed result/logs, retained input after API error |
|
||||||
|
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
|
||||||
|
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||||
|
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||||
|
|
||||||
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
||||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||||
@@ -85,6 +105,16 @@ Adding or changing a frontend route requires:
|
|||||||
Pixel snapshots may be added after these structural assertions pass. Dynamic
|
Pixel snapshots may be added after these structural assertions pass. Dynamic
|
||||||
regions must not be hidden merely to make a pixel threshold pass.
|
regions must not be hidden merely to make a pixel threshold pass.
|
||||||
|
|
||||||
|
To refresh the PHP ranking evidence after building the ignored reference
|
||||||
|
webpack assets, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
REF_RANKING_URL=http://127.0.0.1:3400/sam/ \
|
||||||
|
REF_RANKING_PASSWORD_FILE=/path/to/ignored/user1_password \
|
||||||
|
REF_RANKING_ARTIFACT_DIR=/path/to/ignored/artifacts \
|
||||||
|
node tools/frontend-legacy-parity/reference-rankings.mjs
|
||||||
|
```
|
||||||
|
|
||||||
The nation office suite can be run independently:
|
The nation office suite can be run independently:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -96,6 +126,30 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs
|
|||||||
records desktop/500px computed DOM and screenshots from the PHP service without
|
records desktop/500px computed DOM and screenshots from the PHP service without
|
||||||
changing its product code.
|
changing its product code.
|
||||||
|
|
||||||
|
The NPC policy suite and its reference collector can be run independently:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PLAYWRIGHT_FRONTEND_PORT=15126 \
|
||||||
|
pnpm --filter @sammo-ts/game-frontend test:e2e:npc-policy
|
||||||
|
|
||||||
|
REF_PARITY_USER=refuser1 \
|
||||||
|
REF_PARITY_PASSWORD_FILE=/path/to/password-file \
|
||||||
|
REF_PARITY_BASE_URL=http://127.0.0.1:3400/sam/ \
|
||||||
|
node tools/frontend-legacy-parity/reference-npc-policy.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The collector writes desktop and 500px screenshots plus computed DOM JSON only
|
||||||
|
when `REF_PARITY_ARTIFACT_DIR` is set. It requires an existing reference general
|
||||||
|
owned by the supplied account and never accepts a password on the command line.
|
||||||
|
|
||||||
|
The current-city reference can be collected independently with
|
||||||
|
`tools/frontend-legacy-parity/reference-current-city.mjs`. It requires
|
||||||
|
`REF_PARITY_PASSWORD_FILE` and accepts `REF_PARITY_URL`, `REF_PARITY_USER`, and
|
||||||
|
`REF_PARITY_ARTIFACT_DIR`; the password is read from the ignored secret file
|
||||||
|
and is never written to the artifact. The matching core fixture is
|
||||||
|
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
|
||||||
|
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
|
||||||
|
|
||||||
For a review run that also writes full-page screenshots, create an ignored
|
For a review run that also writes full-page screenshots, create an ignored
|
||||||
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
|
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
|
||||||
suite. The ordinary CI run does not write screenshots after successful tests.
|
suite. The ordinary CI run does not write screenshots after successful tests.
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface TurnCommandEnv {
|
|||||||
initialAllowedTechLevel?: number;
|
initialAllowedTechLevel?: number;
|
||||||
baseGold: number;
|
baseGold: number;
|
||||||
baseRice: number;
|
baseRice: number;
|
||||||
|
generalMinimumGold?: number;
|
||||||
|
generalMinimumRice?: number;
|
||||||
maxResourceActionAmount: number;
|
maxResourceActionAmount: number;
|
||||||
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
||||||
generalActionModules?: Array<GeneralActionModule>;
|
generalActionModules?: Array<GeneralActionModule>;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||||
|
|
||||||
export interface TradeEnvironment {
|
export interface TradeEnvironment {
|
||||||
exchangeFee?: number;
|
exchangeFee?: number;
|
||||||
@@ -46,8 +47,10 @@ export class ActionDefinition<
|
|||||||
if (!parsed || !Number.isFinite(parsed.amount)) {
|
if (!parsed || !Number.isFinite(parsed.amount)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const maxAmount = this.env.maxResourceActionAmount ?? 10_000;
|
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount ?? 10_000);
|
||||||
const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount));
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { buyRice: parsed.buyRice, amount };
|
return { buyRice: parsed.buyRice, amount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
|
denyWithReason,
|
||||||
existsDestGeneral,
|
existsDestGeneral,
|
||||||
friendlyDestGeneral,
|
friendlyDestGeneral,
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
@@ -19,15 +20,13 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '증여';
|
const ACTION_NAME = '증여';
|
||||||
const ACTION_KEY = 'che_증여';
|
const ACTION_KEY = 'che_증여';
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
isGold: z.boolean(),
|
isGold: z.boolean(),
|
||||||
amount: z.preprocess(
|
amount: z.number(),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
destGeneralID: z.number().int().positive(),
|
destGeneralID: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type GiftArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type GiftArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
@@ -51,10 +50,13 @@ export class ActionDefinition<
|
|||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const maxAmount = this.env.maxResourceActionAmount > 0 ? this.env.maxResourceActionAmount : 10000;
|
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount);
|
||||||
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
amount: Math.max(100, Math.min(parsed.amount, maxAmount)),
|
amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +64,12 @@ export class ActionDefinition<
|
|||||||
return [notBeNeutral(), occupiedCity(), suppliedCity()];
|
return [notBeNeutral(), occupiedCity(), suppliedCity()];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, args: GiftArgs): Constraint[] {
|
||||||
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
|
if (ctx.actorId === args.destGeneralID) {
|
||||||
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
|
return [denyWithReason('본인입니다')];
|
||||||
|
}
|
||||||
|
const minGold = this.env.generalMinimumGold ?? 0;
|
||||||
|
const minRice = this.env.generalMinimumRice ?? 500;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
@@ -83,8 +88,8 @@ export class ActionDefinition<
|
|||||||
throw new Error('증여 대상 장수가 없습니다.');
|
throw new Error('증여 대상 장수가 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
|
const minGold = this.env.generalMinimumGold ?? 0;
|
||||||
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
|
const minRice = this.env.generalMinimumRice ?? 500;
|
||||||
|
|
||||||
const resKey = args.isGold ? 'gold' : 'rice';
|
const resKey = args.isGold ? 'gold' : 'rice';
|
||||||
const resName = args.isGold ? '금' : '쌀';
|
const resName = args.isGold ? '금' : '쌀';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '헌납';
|
const ACTION_NAME = '헌납';
|
||||||
const ACTION_KEY = 'che_헌납';
|
const ACTION_KEY = 'che_헌납';
|
||||||
@@ -96,12 +97,23 @@ export class ActionDefinition<
|
|||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver();
|
this.resolver = new ActionResolver();
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): DonateArgs | null {
|
parseArgs(raw: unknown): DonateArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount);
|
||||||
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
amount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: DonateArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: DonateArgs): Constraint[] {
|
||||||
@@ -109,10 +121,12 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] {
|
||||||
|
const minGold = this.env.generalMinimumGold ?? 0;
|
||||||
|
const minRice = this.env.generalMinimumRice ?? 500;
|
||||||
if (args.isGold) {
|
if (args.isGold) {
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => args.amount)];
|
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => minGold)];
|
||||||
}
|
}
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => args.amount)];
|
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => minRice)];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
@@ -128,5 +142,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { isGold: true, amount: 0 },
|
availabilityArgs: { isGold: true, amount: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: ARGS_SCHEMA,
|
||||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const DEFAULT_MIN_RESOURCE_ACTION_AMOUNT = 100;
|
||||||
|
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10_000;
|
||||||
|
const RESOURCE_ACTION_AMOUNT_UNIT = 100;
|
||||||
|
|
||||||
|
export const normalizeResourceActionAmount = (amount: number, configuredMaxAmount: number): number | null => {
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAmount = configuredMaxAmount > 0 ? configuredMaxAmount : DEFAULT_MAX_RESOURCE_ACTION_AMOUNT;
|
||||||
|
const roundedAmount = Math.round(amount / RESOURCE_ACTION_AMOUNT_UNIT) * RESOURCE_ACTION_AMOUNT_UNIT;
|
||||||
|
|
||||||
|
return Math.max(DEFAULT_MIN_RESOURCE_ACTION_AMOUNT, Math.min(roundedAmount, maxAmount));
|
||||||
|
};
|
||||||
@@ -7,8 +7,8 @@ export type InheritBuffType =
|
|||||||
| 'warAvoidRatio'
|
| 'warAvoidRatio'
|
||||||
| 'warCriticalRatio'
|
| 'warCriticalRatio'
|
||||||
| 'warMagicTrialProb'
|
| 'warMagicTrialProb'
|
||||||
| 'success'
|
| 'domesticSuccessProb'
|
||||||
| 'fail'
|
| 'domesticFailProb'
|
||||||
| 'warAvoidRatioOppose'
|
| 'warAvoidRatioOppose'
|
||||||
| 'warCriticalRatioOppose'
|
| 'warCriticalRatioOppose'
|
||||||
| 'warMagicTrialProbOppose';
|
| 'warMagicTrialProbOppose';
|
||||||
@@ -25,7 +25,8 @@ const DOMESTIC_TARGETS = new Set<TriggerDomesticActionType>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => {
|
const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => {
|
||||||
const raw = buff[key];
|
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||||
|
const raw = buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : undefined);
|
||||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,9 @@ const parseInheritBuff = (value: unknown): Record<string, unknown> => {
|
|||||||
return asRecord(value);
|
return asRecord(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveBuffRecord = (context: { general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } } }): Record<string, unknown> => {
|
const resolveBuffRecord = (context: {
|
||||||
|
general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } };
|
||||||
|
}): Record<string, unknown> => {
|
||||||
const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff);
|
const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff);
|
||||||
if (Object.keys(fromTrigger).length > 0) {
|
if (Object.keys(fromTrigger).length > 0) {
|
||||||
return fromTrigger;
|
return fromTrigger;
|
||||||
@@ -58,11 +61,11 @@ const applyDomesticBuff = (
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (varType === 'success') {
|
if (varType === 'success') {
|
||||||
const level = readBuffLevel(buff, 'success');
|
const level = readBuffLevel(buff, 'domesticSuccessProb');
|
||||||
return value + level * 0.01;
|
return value + level * 0.01;
|
||||||
}
|
}
|
||||||
if (varType === 'fail') {
|
if (varType === 'fail') {
|
||||||
const level = readBuffLevel(buff, 'fail');
|
const level = readBuffLevel(buff, 'domesticFailProb');
|
||||||
return value - level * 0.01;
|
return value - level * 0.01;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
@@ -84,11 +87,7 @@ const applyWarBuff = (buff: Record<string, unknown>, statName: WarStatName, valu
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyOpposeWarBuff = (
|
const applyOpposeWarBuff = (buff: Record<string, unknown>, statName: WarStatName, value: number | [number, number]) => {
|
||||||
buff: Record<string, unknown>,
|
|
||||||
statName: WarStatName,
|
|
||||||
value: number | [number, number]
|
|
||||||
) => {
|
|
||||||
if (typeof value !== 'number') {
|
if (typeof value !== 'number') {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { General } from '../src/domain/entities.js';
|
||||||
|
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
|
||||||
|
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
|
||||||
|
|
||||||
|
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Tester',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
injury: 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 20,
|
||||||
|
npcState: 0,
|
||||||
|
triggerState: {
|
||||||
|
flags: {},
|
||||||
|
counters: {},
|
||||||
|
modifiers: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inheritance buff legacy keys', () => {
|
||||||
|
it('applies the canonical legacy domestic buff names', () => {
|
||||||
|
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||||
|
const context = {
|
||||||
|
general: buildGeneral({
|
||||||
|
domesticSuccessProb: 3,
|
||||||
|
domesticFailProb: 2,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53);
|
||||||
|
expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues to read the earlier core success and fail aliases', () => {
|
||||||
|
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||||
|
const context = {
|
||||||
|
general: buildGeneral({
|
||||||
|
success: 2,
|
||||||
|
fail: 1,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52);
|
||||||
|
expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,6 +75,82 @@ export const canonicalFrontendFixture = {
|
|||||||
[2, '진', '#1976d2', 2],
|
[2, '진', '#1976d2', 2],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
bestGeneral: {
|
||||||
|
isUnited: true,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '명 성',
|
||||||
|
valueType: 'int',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '유비',
|
||||||
|
ownerName: '시각검증',
|
||||||
|
nationName: '촉',
|
||||||
|
bgColor: '#006400',
|
||||||
|
fgColor: '#ffffff',
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
value: 12000,
|
||||||
|
printValue: '12,000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '조조',
|
||||||
|
ownerName: '검증계정',
|
||||||
|
nationName: '위',
|
||||||
|
bgColor: '#8b0000',
|
||||||
|
fgColor: '#ffffff',
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
value: 11000,
|
||||||
|
printValue: '11,000',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '계 급',
|
||||||
|
valueType: 'int',
|
||||||
|
entries: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueItems: [
|
||||||
|
{
|
||||||
|
title: '명 마',
|
||||||
|
slot: 'horse',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
itemKey: 'che_명마_15_적토마',
|
||||||
|
itemName: '적토마',
|
||||||
|
itemInfo: '최고의 명마',
|
||||||
|
owner: {
|
||||||
|
id: 1,
|
||||||
|
name: '유비',
|
||||||
|
nationName: '촉',
|
||||||
|
bgColor: '#006400',
|
||||||
|
fgColor: '#ffffff',
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemKey: 'che_명마_15_적토마',
|
||||||
|
itemName: '적토마',
|
||||||
|
itemInfo: '최고의 명마',
|
||||||
|
owner: {
|
||||||
|
id: 0,
|
||||||
|
name: '경매중',
|
||||||
|
nationName: '-',
|
||||||
|
bgColor: '#00582c',
|
||||||
|
fgColor: '#ffffff',
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
hallOptions: [
|
hallOptions: [
|
||||||
{
|
{
|
||||||
season: 1,
|
season: 1,
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||||
|
|
||||||
|
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const general = {
|
||||||
|
id: 1,
|
||||||
|
name: '테스트장수',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
stats: { leadership: 80, strength: 70, intelligence: 90 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 500,
|
||||||
|
train: 100,
|
||||||
|
atmos: 100,
|
||||||
|
injury: 0,
|
||||||
|
experience: 1200,
|
||||||
|
dedication: 900,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalContext = {
|
||||||
|
general,
|
||||||
|
city: {
|
||||||
|
id: 1,
|
||||||
|
name: '낙양',
|
||||||
|
level: 7,
|
||||||
|
nationId: 1,
|
||||||
|
population: 50000,
|
||||||
|
agriculture: 5000,
|
||||||
|
commerce: 5000,
|
||||||
|
security: 5000,
|
||||||
|
defence: 5000,
|
||||||
|
wall: 5000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 2,
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
id: 1,
|
||||||
|
name: '테스트국',
|
||||||
|
color: '#d32f2f',
|
||||||
|
level: 5,
|
||||||
|
gold: 10000,
|
||||||
|
rice: 10000,
|
||||||
|
tech: 1200,
|
||||||
|
typeCode: 'che_군벌',
|
||||||
|
capitalCityId: 1,
|
||||||
|
},
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({
|
||||||
|
generalId,
|
||||||
|
generalName,
|
||||||
|
nationId,
|
||||||
|
nationName,
|
||||||
|
color,
|
||||||
|
icon: '/image/icons/default.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
|
||||||
|
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
|
||||||
|
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
||||||
|
const buildMessages = (permission: number) => ({
|
||||||
|
result: true,
|
||||||
|
public: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
msgType: 'public',
|
||||||
|
src: ownTarget,
|
||||||
|
dest: null,
|
||||||
|
text: '전체 메시지 본문',
|
||||||
|
option: {},
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
national: [
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
msgType: 'national',
|
||||||
|
src: ownTarget,
|
||||||
|
dest: target(0, '', 1, '테스트국', '#d32f2f'),
|
||||||
|
text: '국가 메시지 본문',
|
||||||
|
option: {},
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
private: [
|
||||||
|
{
|
||||||
|
id: 103,
|
||||||
|
msgType: 'private',
|
||||||
|
src: foreignTarget,
|
||||||
|
dest: ownTarget,
|
||||||
|
text: '개인 메시지 본문',
|
||||||
|
option: {},
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diplomacy: [
|
||||||
|
{
|
||||||
|
id: 104,
|
||||||
|
msgType: 'diplomacy',
|
||||||
|
src: foreignTarget,
|
||||||
|
dest: target(0, '', 1, '테스트국', '#d32f2f'),
|
||||||
|
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)',
|
||||||
|
option:
|
||||||
|
permission >= 3
|
||||||
|
? { action: 'noAggression', deletable: false }
|
||||||
|
: { action: 'noAggression', deletable: false, invalid: true },
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sequence: 104,
|
||||||
|
nationId: 1,
|
||||||
|
generalName: general.name,
|
||||||
|
permission,
|
||||||
|
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||||
|
latestRead: { private: 0, diplomacy: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contacts = {
|
||||||
|
nation: [
|
||||||
|
{
|
||||||
|
nationId: 0,
|
||||||
|
mailbox: 9000,
|
||||||
|
name: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
general: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nationId: 1,
|
||||||
|
mailbox: 9001,
|
||||||
|
name: '테스트국',
|
||||||
|
color: '#d32f2f',
|
||||||
|
general: [
|
||||||
|
[1, '테스트장수', 4],
|
||||||
|
[2, '아군군주', 1],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nationId: 2,
|
||||||
|
mailbox: 9002,
|
||||||
|
name: '상대국',
|
||||||
|
color: '#2457a6',
|
||||||
|
general: [
|
||||||
|
[8, '상대외교관', 4],
|
||||||
|
[9, '상대일반', 0],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (
|
||||||
|
page: Page,
|
||||||
|
options: { permission: number; sendError?: string }
|
||||||
|
): Promise<Array<{ operation: string; body: unknown }>> => {
|
||||||
|
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||||
|
await page.addInitScript(
|
||||||
|
({ gameToken, profile }) => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', gameToken);
|
||||||
|
window.localStorage.setItem('sammo-game-profile', profile);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gameToken: fixture.game.session.gameToken,
|
||||||
|
profile: fixture.game.session.profile,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
|
||||||
|
await page.route('**/che/api/events**', (route) => route.abort());
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({ ...fixture.game.lobby, myGeneral: general });
|
||||||
|
}
|
||||||
|
if (operation === 'general.me') return response(generalContext);
|
||||||
|
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
|
||||||
|
if (operation === 'world.getMap') {
|
||||||
|
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
|
||||||
|
}
|
||||||
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
|
return response([]);
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
|
||||||
|
if (operation === 'messages.getContacts') return response(contacts);
|
||||||
|
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
|
if (
|
||||||
|
operation === 'messages.send' ||
|
||||||
|
operation === 'messages.readLatest' ||
|
||||||
|
operation === 'messages.delete' ||
|
||||||
|
operation === 'messages.respond'
|
||||||
|
) {
|
||||||
|
mutations.push({ operation, body });
|
||||||
|
if (operation === 'messages.send' && options.sendError) {
|
||||||
|
return errorResponse(operation, options.sendError);
|
||||||
|
}
|
||||||
|
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
|
||||||
|
}
|
||||||
|
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return mutations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMessages = async (page: Page, viewport: { width: number; height: number }) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
if (viewport.width <= 1024) {
|
||||||
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
|
}
|
||||||
|
await expect(page.locator('.MessagePanel')).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 1000, height: 900 },
|
||||||
|
{ width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => {
|
||||||
|
await installFixture(page, { permission: 4 });
|
||||||
|
await openMessages(page, viewport);
|
||||||
|
const geometry = await page.locator('.MessagePanel').evaluate((panel) => {
|
||||||
|
const required = (selector: string) => panel.querySelector<HTMLElement>(selector)!;
|
||||||
|
const rect = (element: Element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||||
|
};
|
||||||
|
const panelStyle = getComputedStyle(panel);
|
||||||
|
const header = required('.BoardHeader');
|
||||||
|
const plate = required('.msg-plate');
|
||||||
|
const icon = required('.general-icon');
|
||||||
|
return {
|
||||||
|
panel: rect(panel),
|
||||||
|
inputForm: rect(required('.MessageInputForm')),
|
||||||
|
select: rect(required('.message-select')),
|
||||||
|
input: rect(required('.message-text')),
|
||||||
|
submit: rect(required('.message-send')),
|
||||||
|
publicSection: rect(required('.PublicTalk')),
|
||||||
|
nationalSection: rect(required('.NationalTalk')),
|
||||||
|
firstHeader: rect(header),
|
||||||
|
firstPlate: rect(plate),
|
||||||
|
firstIcon: rect(icon),
|
||||||
|
computed: {
|
||||||
|
panelDisplay: panelStyle.display,
|
||||||
|
panelColumns: panelStyle.gridTemplateColumns,
|
||||||
|
panelFontSize: panelStyle.fontSize,
|
||||||
|
headerColor: getComputedStyle(header).color,
|
||||||
|
headerOutlineWidth: getComputedStyle(header).outlineWidth,
|
||||||
|
plateBackgroundColor: getComputedStyle(plate).backgroundColor,
|
||||||
|
plateFontSize: getComputedStyle(plate).fontSize,
|
||||||
|
plateMinHeight: getComputedStyle(plate).minHeight,
|
||||||
|
iconObjectFit: getComputedStyle(icon).objectFit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(geometry.panel.x).toBeCloseTo(0, 0);
|
||||||
|
expect(geometry.panel.width).toBeCloseTo(viewport.width, 0);
|
||||||
|
expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0);
|
||||||
|
expect(geometry.select.height).toBeCloseTo(35.5, 0);
|
||||||
|
expect(geometry.submit.height).toBeCloseTo(35.5, 0);
|
||||||
|
expect(geometry.firstHeader.height).toBeCloseTo(25, 0);
|
||||||
|
expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64);
|
||||||
|
expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 });
|
||||||
|
expect(geometry.computed).toMatchObject({
|
||||||
|
panelFontSize: '14px',
|
||||||
|
headerColor: 'rgb(255, 255, 255)',
|
||||||
|
headerOutlineWidth: '1px',
|
||||||
|
plateBackgroundColor: 'rgb(20, 28, 101)',
|
||||||
|
plateFontSize: '12.5px',
|
||||||
|
plateMinHeight: '64px',
|
||||||
|
iconObjectFit: 'fill',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (viewport.width === 1000) {
|
||||||
|
expect(geometry.computed.panelDisplay).toBe('grid');
|
||||||
|
expect(geometry.computed.panelColumns).toBe('500px 500px');
|
||||||
|
expect(geometry.select.width).toBeCloseTo(166.66, 0);
|
||||||
|
expect(geometry.input.width).toBeCloseTo(666.66, 0);
|
||||||
|
expect(geometry.submit.width).toBeCloseTo(166.66, 0);
|
||||||
|
expect(geometry.publicSection.width).toBeCloseTo(500, 0);
|
||||||
|
expect(geometry.nationalSection.x).toBeCloseTo(500, 0);
|
||||||
|
} else {
|
||||||
|
expect(geometry.computed.panelDisplay).toBe('block');
|
||||||
|
expect(geometry.select.width).toBeCloseTo(250, 0);
|
||||||
|
expect(geometry.input.width).toBeCloseTo(500, 0);
|
||||||
|
expect(geometry.input.height).toBeCloseTo(33.5, 0);
|
||||||
|
expect(geometry.submit.width).toBeCloseTo(250, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = page.locator('.message-send');
|
||||||
|
await submit.hover();
|
||||||
|
expect(
|
||||||
|
await submit.evaluate((element) => ({
|
||||||
|
cursor: getComputedStyle(element).cursor,
|
||||||
|
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||||
|
}))
|
||||||
|
).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' });
|
||||||
|
await submit.focus();
|
||||||
|
expect(
|
||||||
|
await submit.evaluate((element) => ({
|
||||||
|
outlineWidth: getComputedStyle(element).outlineWidth,
|
||||||
|
boxShadow: getComputedStyle(element).boxShadow,
|
||||||
|
}))
|
||||||
|
).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, { permission: 4 });
|
||||||
|
await openMessages(page, { width: 500, height: 900 });
|
||||||
|
|
||||||
|
const select = page.getByLabel('메시지 수신 대상');
|
||||||
|
await expect(select.locator('option[value="9002"]')).toHaveCount(1);
|
||||||
|
await expect(select.locator('option[value="8"]')).toBeDisabled();
|
||||||
|
await expect(select.locator('option[value="9"]')).toBeEnabled();
|
||||||
|
|
||||||
|
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click();
|
||||||
|
await expect(select).toHaveValue('8');
|
||||||
|
|
||||||
|
await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click();
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1);
|
||||||
|
|
||||||
|
const deleteButton = page.locator('.PublicTalk .delete-message');
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await deleteButton.click();
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
|
||||||
|
|
||||||
|
await select.selectOption('9999');
|
||||||
|
await page.getByLabel('메시지 입력').fill('전송 성공');
|
||||||
|
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||||
|
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, {
|
||||||
|
permission: 2,
|
||||||
|
sendError: '공개 메세지를 보낼 수 없습니다.',
|
||||||
|
});
|
||||||
|
await openMessages(page, { width: 500, height: 900 });
|
||||||
|
|
||||||
|
const select = page.getByLabel('메시지 수신 대상');
|
||||||
|
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
|
||||||
|
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
|
||||||
|
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
|
||||||
|
|
||||||
|
await select.selectOption('9999');
|
||||||
|
await page.getByLabel('메시지 입력').fill('차단될 메시지');
|
||||||
|
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||||
|
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
|
||||||
|
await expect(page.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.');
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, extname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const imageRoot = resolve(repositoryRoot, '../../image');
|
||||||
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
|
||||||
|
const operations = (route: Route): string[] => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const installImages = async (page: Page): Promise<void> => {
|
||||||
|
await page.route('**/image/**', async (route) => {
|
||||||
|
const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, '');
|
||||||
|
for (const candidate of [
|
||||||
|
resolve(imageRoot, relative),
|
||||||
|
resolve(imageRoot, 'game', relative),
|
||||||
|
resolve(imageRoot, 'icons', '22.jpg'),
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
const body = await readFile(candidate);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// 다음 공개 image root 후보를 확인한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await route.abort('failed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFixture = {
|
||||||
|
items: {
|
||||||
|
previous: 12_000,
|
||||||
|
lived_month: 240,
|
||||||
|
max_domestic_critical: 80,
|
||||||
|
active_action: 35,
|
||||||
|
combat: 150,
|
||||||
|
sabotage: 60,
|
||||||
|
dex: 42,
|
||||||
|
unifier: 0,
|
||||||
|
tournament: 30,
|
||||||
|
betting: 20,
|
||||||
|
max_belong: 8,
|
||||||
|
},
|
||||||
|
totalPoint: 12_665,
|
||||||
|
inheritConst: {
|
||||||
|
minMonthToAllowInheritItem: 4,
|
||||||
|
inheritBornSpecialPoint: 6000,
|
||||||
|
inheritBornTurntimePoint: 2500,
|
||||||
|
inheritBornCityPoint: 1000,
|
||||||
|
inheritBornStatPoint: 1000,
|
||||||
|
inheritItemUniqueMinPoint: 5000,
|
||||||
|
inheritItemRandomPoint: 3000,
|
||||||
|
inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000],
|
||||||
|
inheritSpecificSpecialPoint: 4000,
|
||||||
|
inheritResetAttrPointBase: [1000, 1000, 2000, 3000],
|
||||||
|
inheritCheckOwnerPoint: 1000,
|
||||||
|
},
|
||||||
|
buffLevels: {
|
||||||
|
warAvoidRatio: 0,
|
||||||
|
warCriticalRatio: 1,
|
||||||
|
warMagicTrialProb: 0,
|
||||||
|
domesticSuccessProb: 0,
|
||||||
|
domesticFailProb: 0,
|
||||||
|
warAvoidRatioOppose: 0,
|
||||||
|
warCriticalRatioOppose: 0,
|
||||||
|
warMagicTrialProbOppose: 0,
|
||||||
|
},
|
||||||
|
resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 },
|
||||||
|
resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 },
|
||||||
|
availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }],
|
||||||
|
availableUnique: [
|
||||||
|
{
|
||||||
|
key: 'che_무기_12_칠성검',
|
||||||
|
name: '칠성검(+12)',
|
||||||
|
rawName: '칠성검',
|
||||||
|
info: '무력을 올려주는 유니크 무기입니다.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availableTargetGenerals: [{ id: 8, name: '조조' }],
|
||||||
|
turnTimeZones: ['00:00'],
|
||||||
|
isUnited: false,
|
||||||
|
currentSpecialWar: 'che_선봉',
|
||||||
|
currentStat: { leadership: 70, strength: 45, intel: 85 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
||||||
|
let buffMutationCount = 0;
|
||||||
|
await installImages(page);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
|
||||||
|
window.localStorage.setItem('sammo-game-profile', 'che');
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const names = operations(route);
|
||||||
|
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = names.map((name) => {
|
||||||
|
if (name === 'inherit.getStatus') return response(statusFixture);
|
||||||
|
if (name === 'lobby.info') {
|
||||||
|
return response({
|
||||||
|
profile: { id: 'che', scenario: 'default', name: '체섭' },
|
||||||
|
world: { year: 200, month: 4 },
|
||||||
|
myGeneral: { id: 7, name: '유비', nationId: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (name === 'inherit.getLogs') {
|
||||||
|
return response([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
year: 200,
|
||||||
|
month: 4,
|
||||||
|
text: '1000 포인트로 장수 소유자 확인',
|
||||||
|
createdAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (name === 'join.getConfig') {
|
||||||
|
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
|
||||||
|
}
|
||||||
|
if (name === 'inherit.buyHiddenBuff') {
|
||||||
|
buffMutationCount += 1;
|
||||||
|
return response({ ok: true, remainPoint: 11_800 });
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(result),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { buffMutationCount: () => buffMutationCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('inheritance management legacy parity', () => {
|
||||||
|
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
|
||||||
|
await installFixture(page);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(gameUrl);
|
||||||
|
await expect(page.locator('#container')).toBeVisible();
|
||||||
|
await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검');
|
||||||
|
|
||||||
|
const desktop = await page.evaluate(() => {
|
||||||
|
const rect = (selector: string) => {
|
||||||
|
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||||
|
return { x: box.x, width: box.width };
|
||||||
|
};
|
||||||
|
const container = getComputedStyle(document.querySelector<HTMLElement>('#container')!);
|
||||||
|
const title = getComputedStyle(document.querySelector<HTMLElement>('.section-title')!);
|
||||||
|
const button = getComputedStyle(document.querySelector<HTMLElement>('.buy-button')!);
|
||||||
|
return {
|
||||||
|
container: rect('#container'),
|
||||||
|
firstPoint: rect('#inherit_sum'),
|
||||||
|
fontFamily: container.fontFamily,
|
||||||
|
fontSize: container.fontSize,
|
||||||
|
backgroundImage: container.backgroundImage,
|
||||||
|
titleBackgroundImage: title.backgroundImage,
|
||||||
|
buttonBackground: button.backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(desktop.container.width).toBe(1000);
|
||||||
|
expect(desktop.container.x).toBe(140);
|
||||||
|
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
|
||||||
|
expect(desktop.fontFamily).toContain('Pretendard');
|
||||||
|
expect(desktop.fontSize).toBe('14px');
|
||||||
|
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||||
|
expect(desktop.titleBackgroundImage).toContain('back_green.jpg');
|
||||||
|
|
||||||
|
const buyButton = page.locator('.buy-button').first();
|
||||||
|
const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
|
await buyButton.hover();
|
||||||
|
const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
|
expect(afterHover).not.toBe(beforeHover);
|
||||||
|
await buyButton.focus();
|
||||||
|
await expect(buyButton).toBeFocused();
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('#container')).toBeVisible();
|
||||||
|
const mobile = await page.evaluate(() => {
|
||||||
|
const container = document.querySelector<HTMLElement>('#container')!.getBoundingClientRect();
|
||||||
|
const first = document.querySelector<HTMLElement>('#inherit_sum')!.getBoundingClientRect();
|
||||||
|
const second = document.querySelector<HTMLElement>('#inherit_previous')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
containerWidth: container.width,
|
||||||
|
firstWidth: first.width,
|
||||||
|
stacked: second.y > first.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobile.containerWidth).toBe(500);
|
||||||
|
expect(mobile.firstWidth).toBeCloseTo(482, 0);
|
||||||
|
expect(mobile.stacked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => {
|
||||||
|
const fixture = await installFixture(page);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.goto(gameUrl);
|
||||||
|
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||||
|
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||||
|
await expect.poll(fixture.buffMutationCount).toBe(1);
|
||||||
|
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
|
||||||
|
await installFixture(page, { failBuff: true });
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.goto(gameUrl);
|
||||||
|
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||||
|
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||||
|
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1');
|
||||||
|
await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
|||||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||||
|
|
||||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
|
||||||
const operationNames = (route: Route): string[] => {
|
const operationNames = (route: Route): string[] => {
|
||||||
@@ -90,6 +91,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
|||||||
sequence: visible ? diplomacyMessage.id : -1,
|
sequence: visible ? diplomacyMessage.id : -1,
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
|
permission: canRespondDiplomacy ? 4 : 2,
|
||||||
canRespondDiplomacy,
|
canRespondDiplomacy,
|
||||||
latestRead: { diplomacy: 0, private: 0 },
|
latestRead: { diplomacy: 0, private: 0 },
|
||||||
});
|
});
|
||||||
@@ -131,6 +133,9 @@ const installFixture = async (
|
|||||||
if (operation === 'messages.getRecent') {
|
if (operation === 'messages.getRecent') {
|
||||||
return response(messageBundle(visible, options.canRespondDiplomacy));
|
return response(messageBundle(visible, options.canRespondDiplomacy));
|
||||||
}
|
}
|
||||||
|
if (operation === 'messages.getContacts') return response({ nation: [] });
|
||||||
|
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
if (operation === 'messages.respond') {
|
if (operation === 'messages.respond') {
|
||||||
mutations.push({ operation, body: requestBody });
|
mutations.push({ operation, body: requestBody });
|
||||||
if (options.acceptResponse) {
|
if (options.acceptResponse) {
|
||||||
@@ -151,9 +156,9 @@ const installFixture = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDiplomacyTab = async (page: Page) => {
|
const openDiplomacyTab = async (page: Page) => {
|
||||||
await page.goto('http://127.0.0.1:15102/che/');
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '외교', exact: true }).last().click();
|
await expect(page.locator('.DiplomacyTalk')).toBeVisible();
|
||||||
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,16 +191,16 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(geometry.buttons).toHaveLength(2);
|
expect(geometry.buttons).toHaveLength(2);
|
||||||
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0);
|
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(0, 0);
|
||||||
expect(geometry.buttons[0]).toMatchObject({
|
expect(geometry.buttons[0]).toMatchObject({
|
||||||
color: 'rgb(143, 209, 143)',
|
color: 'rgb(255, 255, 255)',
|
||||||
fontSize: '11.2px',
|
fontSize: '12.5px',
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
expect(geometry.buttons[1]).toMatchObject({
|
expect(geometry.buttons[1]).toMatchObject({
|
||||||
color: 'rgb(224, 154, 154)',
|
color: 'rgb(255, 255, 255)',
|
||||||
fontSize: '11.2px',
|
fontSize: '12.5px',
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
@@ -234,18 +239,17 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
|
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
|
||||||
const mutations = await installFixture(page, { acceptResponse: false });
|
const mutations = await installFixture(page, { acceptResponse: false });
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto('http://127.0.0.1:15102/che/');
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
|
||||||
|
|
||||||
const responseRow = page.locator('.message-response');
|
const responseRow = page.locator('.message-response');
|
||||||
await expect(responseRow).toBeVisible();
|
await expect(responseRow).toBeVisible();
|
||||||
const itemWidth = await page
|
const itemWidth = await page
|
||||||
.locator('.message-item')
|
.locator('.DiplomacyTalk .msg-plate')
|
||||||
.evaluate((element) => element.getBoundingClientRect().width);
|
.evaluate((element) => element.getBoundingClientRect().width);
|
||||||
expect(itemWidth).toBeGreaterThan(320);
|
expect(itemWidth).toBeGreaterThanOrEqual(389);
|
||||||
expect(itemWidth).toBeLessThanOrEqual(342);
|
expect(itemWidth).toBeLessThanOrEqual(390);
|
||||||
|
|
||||||
page.once('dialog', async (dialog) => {
|
page.once('dialog', async (dialog) => {
|
||||||
expect(dialog.message()).toBe('거절하시겠습니까?');
|
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||||
@@ -272,10 +276,9 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
canRespondDiplomacy: false,
|
canRespondDiplomacy: false,
|
||||||
});
|
});
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto('http://127.0.0.1:15102/che/');
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
|
||||||
|
|
||||||
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
||||||
await expect(accept).toBeDisabled();
|
await expect(accept).toBeDisabled();
|
||||||
@@ -284,7 +287,7 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
return { cursor: style.cursor, opacity: style.opacity };
|
return { cursor: style.cursor, opacity: style.opacity };
|
||||||
})
|
})
|
||||||
).toEqual({ cursor: 'not-allowed', opacity: '0.5' });
|
).toEqual({ cursor: 'not-allowed', opacity: '0.65' });
|
||||||
await accept.click({ force: true });
|
await accept.click({ force: true });
|
||||||
expect(mutations).toHaveLength(0);
|
expect(mutations).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||||
|
const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL;
|
||||||
|
const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1';
|
||||||
|
const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE;
|
||||||
|
const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting');
|
||||||
|
|
||||||
|
if (!staticBaseUrl && !passwordFile) {
|
||||||
|
throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : '';
|
||||||
|
|
||||||
|
const bettingList = {
|
||||||
|
result: true,
|
||||||
|
bettingList: {
|
||||||
|
7: {
|
||||||
|
id: 7,
|
||||||
|
type: 'bettingNation',
|
||||||
|
name: '천통국 예상',
|
||||||
|
finished: false,
|
||||||
|
selectCnt: 2,
|
||||||
|
isExclusive: false,
|
||||||
|
reqInheritancePoint: true,
|
||||||
|
openYearMonth: 2316,
|
||||||
|
closeYearMonth: 2340,
|
||||||
|
winner: null,
|
||||||
|
totalAmount: 800,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
year: 193,
|
||||||
|
month: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bettingDetail = {
|
||||||
|
result: true,
|
||||||
|
bettingInfo: {
|
||||||
|
id: 7,
|
||||||
|
type: 'bettingNation',
|
||||||
|
name: '천통국 예상',
|
||||||
|
finished: false,
|
||||||
|
selectCnt: 2,
|
||||||
|
isExclusive: false,
|
||||||
|
reqInheritancePoint: true,
|
||||||
|
openYearMonth: 2316,
|
||||||
|
closeYearMonth: 2340,
|
||||||
|
candidates: [
|
||||||
|
{ title: '촉', info: '국력: 1200<br>장수 수: 8<br>도시 수: 5', isHtml: true },
|
||||||
|
{ title: '위', info: '국력: 1100<br>장수 수: 7<br>도시 수: 4', isHtml: true },
|
||||||
|
{ title: '오', info: '국력: 900<br>장수 수: 6<br>도시 수: 3', isHtml: true },
|
||||||
|
{ title: '연', info: '국력: 700<br>장수 수: 5<br>도시 수: 2', isHtml: true },
|
||||||
|
{ title: '양', info: '국력: 650<br>장수 수: 4<br>도시 수: 2', isHtml: true },
|
||||||
|
{ title: '형', info: '국력: 600<br>장수 수: 3<br>도시 수: 1', isHtml: true },
|
||||||
|
],
|
||||||
|
winner: null,
|
||||||
|
},
|
||||||
|
bettingDetail: [
|
||||||
|
['[-1]', 500],
|
||||||
|
['[0,1]', 200],
|
||||||
|
['[1,2]', 100],
|
||||||
|
],
|
||||||
|
myBetting: [['[0,1]', 50]],
|
||||||
|
remainPoint: 1200,
|
||||||
|
year: 193,
|
||||||
|
month: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (context, page) => {
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
// The reference entrance polls install status and can keep the PHP session
|
||||||
|
// occupied. Leave it before the login request so the session lock is free.
|
||||||
|
await page.goto('about:blank');
|
||||||
|
await context.clearCookies();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok() || result.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installBettingFixture = async (page) => {
|
||||||
|
await page.route('**/api.php*', async (route) => {
|
||||||
|
const path = new URL(route.request().url()).searchParams.get('path');
|
||||||
|
if (path === 'Betting/GetBettingList') {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === 'Betting/GetBettingDetail') {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(bettingDetail),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountStaticReference = async (page) => {
|
||||||
|
const hweUrl = new URL('hwe/', staticBaseUrl);
|
||||||
|
const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl);
|
||||||
|
await page.setContent(
|
||||||
|
`<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=500">
|
||||||
|
<base href="${hweUrl}">
|
||||||
|
<link rel="stylesheet" href="${new URL('d_shared/common.css', hweUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('vendors.css', assetUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('common_ts.css', assetUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('bootstrap.css', assetUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('v_nationBetting.css', assetUrl)}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="${new URL('d_shared/common_path.js', hweUrl)}"></script>
|
||||||
|
<script src="${new URL('vendors.js', assetUrl)}"></script>
|
||||||
|
<script src="${new URL('common_ts.js', assetUrl)}"></script>
|
||||||
|
<script src="${new URL('bootstrap.js', assetUrl)}"></script>
|
||||||
|
<script src="${new URL('v_nationBetting.js', assetUrl)}"></script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ waitUntil: 'networkidle' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = async (browser, viewport) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: viewport.width, height: viewport.height },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await installBettingFixture(page);
|
||||||
|
if (staticBaseUrl) {
|
||||||
|
await mountStaticReference(page);
|
||||||
|
} else {
|
||||||
|
await login(context, page);
|
||||||
|
await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.locator('.bettingItem').click();
|
||||||
|
await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
const geometry = await page.locator('#container').evaluate((container) => {
|
||||||
|
const rect = (element) => {
|
||||||
|
const value = element.getBoundingClientRect();
|
||||||
|
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||||
|
};
|
||||||
|
const cards = Array.from(container.querySelectorAll('.bettingCandidate'));
|
||||||
|
const firstCard = cards[0];
|
||||||
|
const cardStyle = getComputedStyle(firstCard);
|
||||||
|
const titleStyle = getComputedStyle(firstCard.querySelector('.title'));
|
||||||
|
const optionalRect = (selector) => {
|
||||||
|
const element = container.querySelector(selector);
|
||||||
|
return element ? rect(element) : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
container: rect(container),
|
||||||
|
topBar: rect(container.querySelector('.back_bar')),
|
||||||
|
candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect),
|
||||||
|
candidates: cards.map(rect),
|
||||||
|
bettingForm: optionalRect('.bettingCandidates + .row'),
|
||||||
|
payoutTable: optionalRect('.bettingCandidates + .row + div'),
|
||||||
|
bettingList: optionalRect('.bettingList'),
|
||||||
|
bottomBar: optionalRect('.bottom_bar, .bg0[style]'),
|
||||||
|
cardStyle: {
|
||||||
|
borderWidth: cardStyle.borderWidth,
|
||||||
|
borderRadius: cardStyle.borderRadius,
|
||||||
|
cursor: cardStyle.cursor,
|
||||||
|
fontSize: cardStyle.fontSize,
|
||||||
|
lineHeight: cardStyle.lineHeight,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
fontWeight: titleStyle.fontWeight,
|
||||||
|
textAlign: titleStyle.textAlign,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('.bettingCandidate').first().click();
|
||||||
|
const pickedStyle = await page
|
||||||
|
.locator('.bettingCandidate')
|
||||||
|
.first()
|
||||||
|
.evaluate((candidate) => {
|
||||||
|
const style = getComputedStyle(candidate);
|
||||||
|
return {
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
outlineWidth: style.outlineWidth,
|
||||||
|
titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`);
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' });
|
||||||
|
return { geometry, pickedStyle, screenshotPath };
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const result = {};
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1280, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
result[viewport.name] = await measure(browser, viewport);
|
||||||
|
}
|
||||||
|
const outputPath = resolve(artifactRoot, 'computed-dom.json');
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ export default defineConfig({
|
|||||||
'visual-parity.spec.ts',
|
'visual-parity.spec.ts',
|
||||||
'public-gaps.spec.ts',
|
'public-gaps.spec.ts',
|
||||||
'instant-diplomacy-message.spec.ts',
|
'instant-diplomacy-message.spec.ts',
|
||||||
|
'ingame-message-parity.spec.ts',
|
||||||
'tournament-betting.spec.ts',
|
'tournament-betting.spec.ts',
|
||||||
'dynasty-parity.spec.ts',
|
'dynasty-parity.spec.ts',
|
||||||
|
'inheritance-management.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import { dirname, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const imageRoots = [
|
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||||
resolve(repositoryRoot, '../image/game'),
|
|
||||||
resolve(repositoryRoot, '../../image/game'),
|
|
||||||
];
|
|
||||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
@@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
|||||||
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
|
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
|
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
|
||||||
|
const detail = container.querySelector<HTMLElement>('.betting-detail')!;
|
||||||
|
const detailRect = detail.getBoundingClientRect();
|
||||||
|
const candidateRowElement = container.querySelector<HTMLElement>('.betting-candidates')!;
|
||||||
|
const candidateRow = candidateRowElement.getBoundingClientRect();
|
||||||
|
const candidateCells = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate-cell'));
|
||||||
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
|
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
|
||||||
const cardStyle = getComputedStyle(cards[0]!);
|
const cardStyle = getComputedStyle(cards[0]!);
|
||||||
|
const optionalRect = (selector: string) => {
|
||||||
|
const element = container.querySelector<HTMLElement>(selector);
|
||||||
|
if (!element) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
container: { x: containerRect.x, width: containerRect.width },
|
container: { x: containerRect.x, width: containerRect.width, height: containerRect.height },
|
||||||
bar: { width: bar.width, height: bar.height },
|
bar: { width: bar.width, height: bar.height },
|
||||||
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
|
detail: { x: detailRect.x, width: detailRect.width },
|
||||||
|
candidateRow: {
|
||||||
|
x: candidateRow.x,
|
||||||
|
width: candidateRow.width,
|
||||||
|
},
|
||||||
|
candidateCells: candidateCells.map((cell) => {
|
||||||
|
const rect = cell.getBoundingClientRect();
|
||||||
|
return { x: rect.x, width: rect.width };
|
||||||
|
}),
|
||||||
|
cards: cards.map((card) => {
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
}),
|
||||||
|
bettingForm: optionalRect('.betting-form'),
|
||||||
|
payoutTable: optionalRect('.payout-table'),
|
||||||
|
bettingList: optionalRect('.betting-list'),
|
||||||
|
bottomBar: optionalRect('.betting-footer'),
|
||||||
cardStyle: {
|
cardStyle: {
|
||||||
borderWidth: cardStyle.borderWidth,
|
borderWidth: cardStyle.borderWidth,
|
||||||
borderRadius: cardStyle.borderRadius,
|
borderRadius: cardStyle.borderRadius,
|
||||||
@@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 });
|
||||||
expect(geometry.container).toEqual({ x: 140, width: 1000 });
|
|
||||||
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
|
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
|
||||||
expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true);
|
expect(geometry.detail).toEqual({ x: 140, width: 1000 });
|
||||||
|
expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 });
|
||||||
|
expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25));
|
||||||
|
expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75));
|
||||||
|
expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143));
|
||||||
|
expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53));
|
||||||
|
expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 });
|
||||||
|
expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 });
|
||||||
|
expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 });
|
||||||
|
expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 });
|
||||||
expect(geometry.cardStyle).toEqual({
|
expect(geometry.cardStyle).toEqual({
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderRadius: '7px',
|
borderRadius: '7px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: '18.2px',
|
lineHeight: '21px',
|
||||||
});
|
});
|
||||||
|
await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText(
|
||||||
|
'(50 -> 100.0)'
|
||||||
|
);
|
||||||
|
|
||||||
await page.locator('.betting-candidate').nth(0).click();
|
await page.locator('.betting-candidate').nth(0).click();
|
||||||
await page.locator('.betting-candidate').nth(1).click();
|
await page.locator('.betting-candidate').nth(1).click();
|
||||||
const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => {
|
const pickedStyle = await page
|
||||||
const style = getComputedStyle(candidate);
|
.locator('.betting-candidate')
|
||||||
return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight };
|
.first()
|
||||||
});
|
.evaluate((candidate) => {
|
||||||
|
const style = getComputedStyle(candidate);
|
||||||
|
return {
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
outlineWidth: style.outlineWidth,
|
||||||
|
titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
|
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
|
||||||
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
||||||
expect(pickedStyle.outlineWidth).toBe('1px');
|
expect(pickedStyle.outlineWidth).toBe('1px');
|
||||||
@@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
|||||||
return {
|
return {
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
|
firstX: cards[0]!.getBoundingClientRect().x,
|
||||||
firstWidth: cards[0]!.getBoundingClientRect().width,
|
firstWidth: cards[0]!.getBoundingClientRect().width,
|
||||||
fourthY: cards[3]!.getBoundingClientRect().y,
|
fourthY: cards[3]!.getBoundingClientRect().y,
|
||||||
firstY: cards[0]!.getBoundingClientRect().y,
|
firstY: cards[0]!.getBoundingClientRect().y,
|
||||||
@@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
|||||||
});
|
});
|
||||||
expect(geometry.x).toBe(0);
|
expect(geometry.x).toBe(0);
|
||||||
expect(geometry.width).toBe(500);
|
expect(geometry.width).toBe(500);
|
||||||
expect(geometry.firstWidth).toBeCloseTo(161.328125, 3);
|
expect(geometry.firstX).toBe(0);
|
||||||
|
expect(geometry.firstWidth).toBe(164.328125);
|
||||||
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
|
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
|
||||||
|
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
@@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
|||||||
expect(geometry.tableWidth).toBe(1000);
|
expect(geometry.tableWidth).toBe(1000);
|
||||||
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
||||||
expect(geometry.headerWidths).toEqual([
|
expect(geometry.headerWidths).toEqual([
|
||||||
104.609375,
|
104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80,
|
||||||
104.609375,
|
|
||||||
69.734375,
|
|
||||||
121.015625,
|
|
||||||
69.734375,
|
|
||||||
90.25,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
80,
|
|
||||||
80.109375,
|
80.109375,
|
||||||
]);
|
]);
|
||||||
expect(geometry.headerStyle).toEqual({
|
expect(geometry.headerStyle).toEqual({
|
||||||
@@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
|||||||
lineHeight: '18.2px',
|
lineHeight: '18.2px',
|
||||||
});
|
});
|
||||||
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
|
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
|
||||||
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
|
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS(
|
||||||
|
'color',
|
||||||
|
'rgb(135, 206, 235)'
|
||||||
|
);
|
||||||
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
|
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
|
||||||
await personality.hover();
|
await personality.hover();
|
||||||
await expect(personality.getByRole('tooltip')).toBeVisible();
|
await expect(personality.getByRole('tooltip')).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||||
|
const username = process.env.REF_PARITY_USER ?? 'refadmin';
|
||||||
|
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
|
||||||
|
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-current-city');
|
||||||
|
|
||||||
|
if (!passwordFile) {
|
||||||
|
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1200, height: 900 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'Asia/Seoul',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
const loginResult = await loginResponse.json();
|
||||||
|
if (!loginResponse.ok() || loginResult.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
const mapCity = page.locator('a[href*="b_currentCity.php?citylist="]').first();
|
||||||
|
const hasMapCity = await mapCity.isVisible({ timeout: 8_000 }).catch(() => false);
|
||||||
|
let mapInteraction;
|
||||||
|
if (hasMapCity) {
|
||||||
|
mapInteraction = await mapCity.evaluate((element) => ({
|
||||||
|
available: true,
|
||||||
|
href: element.getAttribute('href'),
|
||||||
|
cursor: getComputedStyle(element).cursor,
|
||||||
|
rect: (() => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||||
|
})(),
|
||||||
|
}));
|
||||||
|
await mapCity.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
if (!page.url().includes('b_currentCity.php?citylist=')) {
|
||||||
|
throw new Error(`Reference map click did not open current city: ${page.url()}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mapInteraction = {
|
||||||
|
available: false,
|
||||||
|
pageUrl: page.url(),
|
||||||
|
pageTitle: await page.title(),
|
||||||
|
};
|
||||||
|
await page.goto(new URL('hwe/b_currentCity.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurements = await page.evaluate(() => {
|
||||||
|
const measure = (element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||||
|
style: {
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderCollapse: style.borderCollapse,
|
||||||
|
padding: style.padding,
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const tables = [...document.querySelectorAll('table')];
|
||||||
|
const selector = document.querySelector('#citySelector');
|
||||||
|
const stats = tables.find((table) => table.textContent?.includes('90병장'));
|
||||||
|
const generals = document.querySelector('#general_list')?.closest('table');
|
||||||
|
const firstIcon = document.querySelector('.generalIcon');
|
||||||
|
const title = stats?.querySelector('tr:first-child td');
|
||||||
|
return {
|
||||||
|
body: measure(document.body),
|
||||||
|
tables: tables.map(measure),
|
||||||
|
selector: selector ? measure(selector) : null,
|
||||||
|
stats: stats ? measure(stats) : null,
|
||||||
|
generals: generals ? measure(generals) : null,
|
||||||
|
firstIcon: firstIcon
|
||||||
|
? {
|
||||||
|
...measure(firstIcon),
|
||||||
|
naturalWidth: firstIcon.naturalWidth,
|
||||||
|
naturalHeight: firstIcon.naturalHeight,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
title: title ? measure(title) : null,
|
||||||
|
document: {
|
||||||
|
width: document.documentElement.scrollWidth,
|
||||||
|
height: document.documentElement.scrollHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'reference-current-city-desktop.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
resolve(artifactRoot, 'reference-current-city-computed-dom.json'),
|
||||||
|
`${JSON.stringify({ mapInteraction, currentCity: measurements }, null, 2)}\n`
|
||||||
|
);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`);
|
||||||
|
await context.close();
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||||
|
const username = process.env.REF_GENERAL_USER ?? 'refuser1';
|
||||||
|
const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
|
||||||
|
const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-general-lists');
|
||||||
|
if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
|
||||||
|
const measure = async (page, selectors) =>
|
||||||
|
page.evaluate((items) => {
|
||||||
|
const result = {};
|
||||||
|
for (const [name, selector] of Object.entries(items)) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) {
|
||||||
|
result[name] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
result[name] = {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
style: {
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
borderCollapse: style.borderCollapse,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
color: style.color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { elements: result, documentWidth: document.documentElement.scrollWidth };
|
||||||
|
}, selectors);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1200, height: 900 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||||
|
const salt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(salt + password + salt)
|
||||||
|
.digest('hex');
|
||||||
|
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
const loginResult = await login.json();
|
||||||
|
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
|
||||||
|
|
||||||
|
const output = {};
|
||||||
|
for (const [name, path, selectors] of [
|
||||||
|
[
|
||||||
|
'generals',
|
||||||
|
'hwe/b_myGenInfo.php',
|
||||||
|
{
|
||||||
|
body: 'body',
|
||||||
|
title: 'body > table:first-of-type',
|
||||||
|
list: 'body > table:nth-of-type(2)',
|
||||||
|
firstRow: 'body > table:nth-of-type(2) tr:nth-child(2)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'secret',
|
||||||
|
'hwe/b_genList.php',
|
||||||
|
{
|
||||||
|
body: 'body',
|
||||||
|
title: 'body > table:first-of-type',
|
||||||
|
summary: 'body > table:nth-of-type(2)',
|
||||||
|
list: '#general_list',
|
||||||
|
firstRow: '#general_list tbody tr:first-child',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
await page.goto(new URL(path, baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
output[name] = await measure(page, selectors);
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, `ref-${name}.png`), fullPage: true });
|
||||||
|
}
|
||||||
|
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_MENU_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||||
|
const username = process.env.REF_MENU_USER ?? 'refuser1';
|
||||||
|
const passwordFile = process.env.REF_MENU_PASSWORD_FILE;
|
||||||
|
const artifactRoot = resolve(process.env.REF_MENU_ARTIFACT_DIR ?? 'test-results/reference-ingame-menus');
|
||||||
|
|
||||||
|
if (!passwordFile) {
|
||||||
|
throw new Error('REF_MENU_PASSWORD_FILE is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
|
||||||
|
const login = async (context, page) => {
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok() || result.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rectAndStyle = (element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
style: {
|
||||||
|
display: style.display,
|
||||||
|
gridTemplateColumns: style.gridTemplateColumns,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderTopColor: style.borderTopColor,
|
||||||
|
borderTopWidth: style.borderTopWidth,
|
||||||
|
padding: style.padding,
|
||||||
|
margin: style.margin,
|
||||||
|
cursor: style.cursor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = async (page, selectors) =>
|
||||||
|
page.evaluate(
|
||||||
|
({ selectors, measureSource }) => {
|
||||||
|
const measureElement = new Function(`return (${measureSource})`)();
|
||||||
|
const result = {};
|
||||||
|
for (const [name, selector] of Object.entries(selectors)) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
result[name] = element ? measureElement(element) : null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
elements: result,
|
||||||
|
document: {
|
||||||
|
width: document.documentElement.scrollWidth,
|
||||||
|
height: document.documentElement.scrollHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ selectors, measureSource: rectAndStyle.toString() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const output = {};
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1000, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: viewport.width, height: viewport.height },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'Asia/Seoul',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const consoleErrors = [];
|
||||||
|
const failedResources = [];
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||||
|
});
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.status() >= 400) failedResources.push(`${response.status()} ${response.url()}`);
|
||||||
|
});
|
||||||
|
await login(context, page);
|
||||||
|
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/b_myPage.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
await page.locator('#container').waitFor();
|
||||||
|
const myPage = await measure(page, {
|
||||||
|
body: 'body',
|
||||||
|
container: '#container',
|
||||||
|
title: '#container > .row:first-child',
|
||||||
|
infoColumn: '#container > .row:nth-child(2) > .col:first-child',
|
||||||
|
settingsColumn: '#container > .row:nth-child(2) > .col:nth-child(2)',
|
||||||
|
saveButton: '#set_my_setting',
|
||||||
|
firstSelect: 'select',
|
||||||
|
customCss: '#custom_css',
|
||||||
|
firstLogTitle: '#generalActionPlate',
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, `ref-my-page-${viewport.name}.png`), fullPage: true });
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/a_traffic.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
const traffic = await measure(page, {
|
||||||
|
body: 'body',
|
||||||
|
title: 'body > table:first-of-type',
|
||||||
|
chartLayout: 'body > table:nth-of-type(2)',
|
||||||
|
refreshChart: 'body > table:nth-of-type(2) > tbody > tr > td:first-child > table',
|
||||||
|
onlineChart: 'body > table:nth-of-type(2) > tbody > tr > td:nth-child(2) > table',
|
||||||
|
firstBigBar: '.big_bar',
|
||||||
|
suspectTable: 'body > table:nth-of-type(3)',
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, `ref-traffic-${viewport.name}.png`), fullPage: true });
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/a_npcList.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
const npcList = await measure(page, {
|
||||||
|
body: 'body',
|
||||||
|
title: 'body > table:first-of-type',
|
||||||
|
sortSelect: 'select[name="type"]',
|
||||||
|
list: 'body > table:nth-of-type(2)',
|
||||||
|
header: 'body > table:nth-of-type(2) tr:first-child',
|
||||||
|
footer: 'body > table:nth-of-type(3)',
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, `ref-npc-list-${viewport.name}.png`), fullPage: true });
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/v_battleCenter.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
try {
|
||||||
|
await page.locator('#container').waitFor({ timeout: 10_000 });
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Reference battle center failed to mount: ${JSON.stringify({
|
||||||
|
url: page.url(),
|
||||||
|
text: (await page.locator('body').innerText()).slice(0, 500),
|
||||||
|
html: (await page.content()).slice(-1_000),
|
||||||
|
consoleErrors,
|
||||||
|
failedResources,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const battleCenter = await measure(page, {
|
||||||
|
body: 'body',
|
||||||
|
container: '#container',
|
||||||
|
topBar: '#container > :first-child',
|
||||||
|
selectorRow: '#container > .row:nth-child(2)',
|
||||||
|
previousButton: '#container > .row:nth-child(2) button:first-child',
|
||||||
|
firstSelect: '#container > .row:nth-child(2) select:first-of-type',
|
||||||
|
generalCard: '.header-cell',
|
||||||
|
firstLogHeader: '.header-cell:nth-of-type(1)',
|
||||||
|
});
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, `ref-battle-center-${viewport.name}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
output[viewport.name] = { myPage, traffic, npcList, battleCenter };
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(output) })}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_MESSAGE_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||||
|
const username = process.env.REF_MESSAGE_USER ?? 'refuser1';
|
||||||
|
const passwordFile = process.env.REF_MESSAGE_PASSWORD_FILE;
|
||||||
|
const artifactRoot = process.env.REF_MESSAGE_ARTIFACT_DIR;
|
||||||
|
|
||||||
|
if (!passwordFile) {
|
||||||
|
throw new Error('REF_MESSAGE_PASSWORD_FILE is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
|
||||||
|
const login = async (context, page) => {
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok() || result.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureGeneral = async (page) => {
|
||||||
|
await page.goto(new URL('hwe/index.php', baseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
if (await page.locator('.MessagePanel').isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
const create = page.getByRole('button', { name: '장수 생성', exact: true });
|
||||||
|
await create.waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await create.click();
|
||||||
|
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 60_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = async (browser, name, viewport) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await login(context, page);
|
||||||
|
await ensureGeneral(page);
|
||||||
|
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
await page.locator('.BoardHeader').first().waitFor({ state: 'visible' });
|
||||||
|
const marker = `computed-dom-${name}-${Date.now()}`;
|
||||||
|
await page.locator('.MessageInputForm select').selectOption('9999');
|
||||||
|
await page.locator('.MessageInputForm input').fill(marker);
|
||||||
|
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||||
|
await page.getByText(marker, { exact: true }).waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
const path = resolve(artifactRoot, `message-ref-${name}.png`);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await page.locator('.MessagePanel').screenshot({
|
||||||
|
path,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const rect = (element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: box.x,
|
||||||
|
y: box.y,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const required = (selector) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) throw new Error(`Missing reference selector: ${selector}`);
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const optionalRect = (selector) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
return element ? rect(element) : null;
|
||||||
|
};
|
||||||
|
const style = (selector) => getComputedStyle(required(selector));
|
||||||
|
const input = required('.MessageInputForm input');
|
||||||
|
const select = required('.MessageInputForm select');
|
||||||
|
const submit = required('#msg_submit-col button');
|
||||||
|
const firstPlate = document.querySelector('.msg_plate');
|
||||||
|
const firstIcon = document.querySelector('.msg_plate .generalIcon');
|
||||||
|
const panelStyle = style('.MessagePanel');
|
||||||
|
const headerStyle = style('.BoardHeader');
|
||||||
|
const plateStyle = firstPlate ? getComputedStyle(firstPlate) : null;
|
||||||
|
const iconStyle = firstIcon ? getComputedStyle(firstIcon) : null;
|
||||||
|
return {
|
||||||
|
panel: rect(required('.MessagePanel')),
|
||||||
|
inputForm: rect(required('.MessageInputForm')),
|
||||||
|
select: rect(select),
|
||||||
|
input: rect(input),
|
||||||
|
submit: rect(submit),
|
||||||
|
publicSection: rect(required('.PublicTalk')),
|
||||||
|
nationalSection: rect(required('.NationalTalk')),
|
||||||
|
privateSection: rect(required('.PrivateTalk')),
|
||||||
|
diplomacySection: rect(required('.DiplomacyTalk')),
|
||||||
|
firstHeader: rect(required('.BoardHeader')),
|
||||||
|
firstPlate: optionalRect('.msg_plate'),
|
||||||
|
firstIcon: optionalRect('.msg_plate .generalIcon'),
|
||||||
|
computed: {
|
||||||
|
panelDisplay: panelStyle.display,
|
||||||
|
panelColumns: panelStyle.gridTemplateColumns,
|
||||||
|
panelFontSize: panelStyle.fontSize,
|
||||||
|
headerColor: headerStyle.color,
|
||||||
|
headerOutlineWidth: headerStyle.outlineWidth,
|
||||||
|
headerBackgroundImage: headerStyle.backgroundImage,
|
||||||
|
plateBackgroundColor: plateStyle?.backgroundColor ?? null,
|
||||||
|
plateFontSize: plateStyle?.fontSize ?? null,
|
||||||
|
plateMinHeight: plateStyle?.minHeight ?? null,
|
||||||
|
iconObjectFit: iconStyle?.objectFit ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = page.locator('#msg_submit-col button');
|
||||||
|
await submit.hover();
|
||||||
|
const hover = await submit.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
cursor: style.cursor,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await submit.focus();
|
||||||
|
const focus = await submit.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
outline: style.outline,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const markerPlate = page.locator('.msg_plate').filter({ hasText: marker });
|
||||||
|
const deleteButton = markerPlate.locator('.btn-delete-msg');
|
||||||
|
if (await deleteButton.isVisible()) {
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await deleteButton.click();
|
||||||
|
}
|
||||||
|
return { ...result, interaction: { hover, focus } };
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const measurements = {
|
||||||
|
desktop: await measure(browser, 'desktop', { width: 1000, height: 900 }),
|
||||||
|
mobile: await measure(browser, 'mobile', { width: 500, height: 900 }),
|
||||||
|
};
|
||||||
|
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||||
|
const username = process.env.REF_PARITY_USER ?? 'refadmin';
|
||||||
|
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
|
||||||
|
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-npc-policy');
|
||||||
|
|
||||||
|
if (!passwordFile) {
|
||||||
|
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
|
||||||
|
const login = async (context, page) => {
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok() || result.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const result = {};
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1000, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: viewport.width, height: viewport.height },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'Asia/Seoul',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await login(context, page);
|
||||||
|
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
await page.goto(new URL('hwe/v_NPCControl.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
try {
|
||||||
|
await page.locator('#container').waitFor({ timeout: 10_000 });
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Reference NPC policy failed to mount: ${JSON.stringify({
|
||||||
|
url: page.url(),
|
||||||
|
text: (await page.locator('body').innerText()).slice(0, 500),
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[viewport.name] = await page.evaluate(() => {
|
||||||
|
const measure = (selector) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
style: {
|
||||||
|
display: style.display,
|
||||||
|
gridTemplateColumns: style.gridTemplateColumns,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
padding: style.padding,
|
||||||
|
margin: style.margin,
|
||||||
|
cursor: style.cursor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
body: measure('body'),
|
||||||
|
container: measure('#container'),
|
||||||
|
topBackBar: measure('body > :first-child'),
|
||||||
|
sectionBar: measure('.section_bar'),
|
||||||
|
formList: measure('.form_list'),
|
||||||
|
firstField: measure('.form_list > .col'),
|
||||||
|
firstInput: measure('input[type="number"]'),
|
||||||
|
firstInfoButton: measure('.form_list button'),
|
||||||
|
controlBar: measure('.control_bar'),
|
||||||
|
resetButton: measure('.reset_btn'),
|
||||||
|
submitButton: measure('.submit_btn'),
|
||||||
|
priorityGrid: measure('.half_section_left'),
|
||||||
|
priorityColumn: measure('.priority-list'),
|
||||||
|
priorityItem: measure('.priority-list .list-group-item'),
|
||||||
|
helpButton: measure('.priority_info button'),
|
||||||
|
document: {
|
||||||
|
width: document.documentElement.scrollWidth,
|
||||||
|
height: document.documentElement.scrollHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, `ref-npc-policy-${viewport.name}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user