Complete legacy-compatible in-game information menus

This commit is contained in:
2026-07-26 05:20:32 +00:00
parent 8fac491c9d
commit 0127fd501a
20 changed files with 3269 additions and 940 deletions
+10 -29
View File
@@ -37,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => {
};
const resolveUserSettings = (meta: Record<string, unknown>) => {
const settings = asRecord(meta.userSettings);
const mysetRaw = settings.myset;
// The legacy general columns are persisted at the top level of General.meta.
// Keep reading the short-lived nested shape for installations that ran the
// initial rewrite implementation before this compatibility fix.
const nestedSettings = asRecord(meta.userSettings);
const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key];
const mysetRaw = readSetting('myset');
const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null;
return {
tnmt: readNumber(settings.tnmt, 1),
defence_train: readNumber(settings.defence_train, 80),
use_treatment: readNumber(settings.use_treatment, 10),
use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1),
tnmt: readNumber(readSetting('tnmt'), 1),
defence_train: readNumber(readSetting('defence_train'), 80),
use_treatment: readNumber(readSetting('use_treatment'), 10),
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
myset,
};
};
@@ -262,29 +266,6 @@ export const generalRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
const metaRecord = asRecord(general.meta);
const prevSettings = asRecord(metaRecord.userSettings);
const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset)
? prevSettings.myset
: null;
const nextSettings = {
...prevSettings,
...input,
} as Record<string, unknown>;
if (typeof prevMyset === 'number') {
nextSettings.myset = Math.max(0, prevMyset - 1);
}
await ctx.db.general.update({
where: { id: general.id },
data: {
meta: {
...metaRecord,
userSettings: nextSettings,
},
} as any,
});
return { ok: true };
}),
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
@@ -1,84 +1,279 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../../context.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, loadTraitNames, mapGeneralList, resolveChiefStatMin } from '../shared.js';
import {
assertNationAccess,
loadTraitNames,
resolveNationPermission,
resolveOfficerCity,
} from '../shared.js';
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
assertNationAccess(general);
const MAX_DEDICATION_LEVEL = 10;
const [nation, cityRows, troopRows, generalRows, worldState] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: general.nationId },
select: {
id: true,
name: true,
color: true,
level: true,
typeCode: true,
capitalCityId: true,
meta: true,
},
}),
ctx.db.city.findMany({ select: { id: true, name: true } }),
ctx.db.troop.findMany({ select: { troopLeaderId: true, name: true } }),
ctx.db.general.findMany({
where: { nationId: general.nationId },
select: {
id: true,
name: true,
npcState: true,
nationId: true,
cityId: true,
troopId: true,
picture: true,
imageServer: true,
officerLevel: true,
leadership: true,
strength: true,
intel: true,
experience: true,
dedication: true,
injury: true,
gold: true,
rice: true,
crew: true,
personalCode: true,
specialCode: true,
special2Code: true,
meta: true,
penalty: true,
},
orderBy: { id: 'asc' },
}),
ctx.db.worldState.findFirst(),
]);
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 experienceLevel = (experience: number): number =>
Math.max(0, Math.min(100, experience < 1_000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10))));
const dedicationLevel = (dedication: number): number =>
Math.max(0, Math.min(MAX_DEDICATION_LEVEL, Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10)));
const dedicationLevelText = (level: number): string =>
level === 0 ? '무품관' : `${MAX_DEDICATION_LEVEL - level + 1}품관`;
const honorText = (experience: number): string => {
const levels: Array<[number, string]> = [
[640, '전무'],
[2_560, '무명'],
[5_760, '신동'],
[10_240, '약간'],
[16_000, '평범'],
[23_040, '지역적'],
[31_360, '전국적'],
[40_960, '세계적'],
[45_000, '유명'],
[51_840, '명사'],
[55_000, '호걸'],
[64_000, '효웅'],
[77_440, '영웅'],
];
return levels.find(([limit]) => experience < limit)?.[1] ?? '구세주';
};
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
if (officerLevel === 12) return nationLevel * 2;
if (officerLevel >= 5) return nationLevel;
return 0;
};
const woundedStat = (value: number, injury: number): number =>
injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value;
const defenceTrainText = (value: number): string => {
if (value === 999) return '×';
if (value >= 90) return '☆';
if (value >= 80) return '◎';
if (value >= 60) return '○';
return '△';
};
const loadNationGeneralData = async (ctx: GameApiContext) => {
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,
typeCode: true,
capitalCityId: true,
meta: true,
},
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const viewerPermission = resolveNationPermission(me, nation.meta, true);
const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name]));
const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap);
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
const [cityRows, troopRows, 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 [accessRows, turnRows] = await Promise.all([
ctx.db.generalAccessLog.findMany({
where: { generalId: { in: generalIds } },
select: { generalId: true, refreshScore: true, refreshScoreTotal: true },
}),
viewerPermission >= 1
? ctx.db.generalTurn.findMany({
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
})
: Promise.resolve([]),
]);
const cityNames = new Map(cityRows.map((city) => [city.id, city.name]));
const troopNames = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row]));
const turnsByGeneral = new Map<number, string[]>();
for (const turn of turnRows) {
const turns = turnsByGeneral.get(turn.generalId) ?? [];
turns[turn.turnIdx] = turn.actionCode;
turnsByGeneral.set(turn.generalId, turns);
}
const [personalityMap, domesticMap, warMap] = await Promise.all([
loadTraitNames(generalRows.map((general) => general.personalCode), 'personality'),
loadTraitNames(generalRows.map((general) => general.specialCode), 'domestic'),
loadTraitNames(generalRows.map((general) => general.special2Code), 'war'),
]);
const generals = generalRows.map((general) => {
const meta = asRecord(general.meta);
const officerCity = resolveOfficerCity(meta);
const access = accessByGeneral.get(general.id);
const dedLevel = dedicationLevel(general.dedication);
const actualOfficerLevel = general.officerLevel;
const visibleOfficerLevel =
viewerPermission >= 1 || actualOfficerLevel >= 5 ? actualOfficerLevel : Math.min(1, actualOfficerLevel);
const bonus = leadershipBonus(actualOfficerLevel, nation.level);
const detail =
viewerPermission >= 1
? {
officerLevel: actualOfficerLevel,
officerCity,
officerCityName: officerCity > 0 ? (cityNames.get(officerCity) ?? null) : null,
cityId: general.cityId,
cityName: cityNames.get(general.cityId) ?? null,
troopId: general.troopId,
troopName: troopNames.get(general.troopId) ?? null,
defenceTrain: readNumber(meta, ['defenceTrain', 'defence_train'], 80),
crewTypeId: general.crewTypeId,
crew: general.crew,
train: general.train,
atmos: general.atmos,
experience: general.experience,
dedication: general.dedication,
turnTime: general.turnTime.toISOString(),
recentWarTime: general.recentWarTime?.toISOString() ?? null,
killTurn: readNumber(meta, ['killturn', 'killTurn']),
refreshScore: access?.refreshScore ?? 0,
reservedCommands: general.npcState < 2 ? (turnsByGeneral.get(general.id) ?? []) : [],
}
: null;
return {
id: general.id,
name: general.name,
npcState: general.npcState,
picture: general.picture,
imageServer: general.imageServer,
injury: general.injury,
stats: {
leadership: woundedStat(general.leadership, general.injury),
strength: woundedStat(general.strength, general.injury),
intelligence: woundedStat(general.intel, general.injury),
},
leadershipBonus: bonus,
officerLevel: visibleOfficerLevel,
experienceLevel: experienceLevel(general.experience),
honorText: honorText(general.experience),
dedicationLevel: dedLevel,
dedicationLevelText: dedicationLevelText(dedLevel),
bill: dedLevel * 200 + 400,
gold: general.gold,
rice: general.rice,
age: general.age,
belong: readNumber(meta, ['belong']),
refreshScoreTotal: access?.refreshScoreTotal ?? 0,
personality: general.personalCode === 'None' ? null : (personalityMap.get(general.personalCode) ?? null),
specialDomestic:
general.specialCode === 'None' ? null : (domesticMap.get(general.specialCode) ?? null),
specialWar: general.special2Code === 'None' ? null : (warMap.get(general.special2Code) ?? null),
detail,
};
});
return {
me,
nation: {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
typeCode: nation.typeCode,
type: {
key: nation.typeCode,
name: nationTrait?.name ?? nation.typeCode,
info: nationTrait?.info ?? '',
},
capitalCityId: nation.capitalCityId ?? 0,
},
chiefStatMin: resolveChiefStatMin(worldState),
generals: list,
viewerPermission,
generals,
};
};
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
const data = await loadNationGeneralData(ctx);
return {
nation: data.nation,
viewer: { generalId: data.me.id, permission: data.viewerPermission },
generals: data.generals,
};
});
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
const data = await loadNationGeneralData(ctx);
if (data.viewerPermission < 1) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.',
});
}
const visibleGenerals = data.generals.filter((general) => general.npcState !== 5);
const summaryBase = visibleGenerals.reduce(
(summary, general) => {
const detail = general.detail;
if (!detail) return summary;
summary.gold += general.gold;
summary.rice += general.rice;
summary.crew += detail.crew;
if (detail.crew > 0) {
for (const threshold of [90, 80, 60] as const) {
if (detail.train >= threshold && detail.atmos >= threshold) {
summary.readiness[threshold].crew += detail.crew;
summary.readiness[threshold].generals += 1;
}
}
}
return summary;
},
{
gold: 0,
rice: 0,
crew: 0,
readiness: {
90: { crew: 0, generals: 0 },
80: { crew: 0, generals: 0 },
60: { crew: 0, generals: 0 },
},
}
);
const generalCount = visibleGenerals.length;
return {
nation: data.nation,
viewer: { generalId: data.me.id, permission: data.viewerPermission },
summary: {
...summaryBase,
generalCount,
averageGold: generalCount ? summaryBase.gold / generalCount : 0,
averageRice: generalCount ? summaryBase.rice / generalCount : 0,
},
generals: data.generals.map((general) => ({
...general,
defenceTrainText: defenceTrainText(general.detail?.defenceTrain ?? 0),
})),
};
});
+2 -1
View File
@@ -4,7 +4,7 @@ import { changePermission } from './endpoints/changePermission.js';
import { getBattleCenter } from './endpoints/getBattleCenter.js';
import { getChiefCenter } from './endpoints/getChiefCenter.js';
import { getCityOverview } from './endpoints/getCityOverview.js';
import { getGeneralList } from './endpoints/getGeneralList.js';
import { getGeneralList, getSecretGeneralList } from './endpoints/getGeneralList.js';
import { getGeneralLog } from './endpoints/getGeneralLog.js';
import { getNationInfo } from './endpoints/getNationInfo.js';
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
@@ -21,6 +21,7 @@ import { setSecretLimit } from './endpoints/setSecretLimit.js';
export const nationRouter = router({
getNationInfo,
getGeneralList,
getSecretGeneralList,
getCityOverview,
getPersonnelInfo,
getStratFinan,
+117
View File
@@ -42,6 +42,14 @@ type NationCountRow = {
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
type TrafficHistoryItem = {
year: number;
month: number;
refresh: number;
online: number;
date: string;
};
const PUBLIC_CACHE_TTL_SECONDS = 600;
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
@@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
};
const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => {
if (!Array.isArray(value)) {
return [];
}
const result: TrafficHistoryItem[] = [];
for (const item of value) {
const row = asRecord(item);
const year = readFiniteMetaNumber(row, 'year');
const month = readFiniteMetaNumber(row, 'month');
const refresh = readFiniteMetaNumber(row, 'refresh');
const online = readFiniteMetaNumber(row, 'online');
const date = typeof row.date === 'string' ? row.date : '';
if (year > 0 && month > 0 && date) {
result.push({ year, month, refresh, online, date });
}
}
return result;
};
const compareString = (left: string, right: string): number => {
if (left === right) {
return 0;
@@ -222,6 +250,95 @@ export const publicRouter = router({
getNationList: procedure.query(async ({ ctx }) => {
return loadCachedNationList(ctx);
}),
getTraffic: procedure.query(async ({ ctx }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const meta = asRecord(worldState.meta);
const rawOnlineSince = meta.lastTurnTime ?? meta.turntime;
const parsedOnlineSince =
typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date
? new Date(rawOnlineSince)
: null;
const onlineSince =
parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime())
? parsedOnlineSince
: new Date(Date.now() - worldState.tickSeconds * 1_000);
const [accessTotal, currentOnline, topAccess] = await Promise.all([
ctx.db.generalAccessLog.aggregate({
_sum: {
refresh: true,
refreshScoreTotal: true,
},
}),
ctx.db.generalAccessLog.count({
where: {
lastRefresh: {
gte: onlineSince,
},
},
}),
ctx.db.generalAccessLog.findMany({
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }],
take: 5,
select: {
generalId: true,
refresh: true,
refreshScoreTotal: true,
},
}),
]);
const generalIds = topAccess.map((entry) => entry.generalId);
const generalRows =
generalIds.length > 0
? await ctx.db.general.findMany({
where: { id: { in: generalIds } },
select: { id: true, name: true },
})
: [];
const generalName = new Map(generalRows.map((general) => [general.id, general.name]));
const totalRefresh = accessTotal._sum.refresh ?? 0;
const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh);
const history = parseTrafficHistory(meta.recentTraffic);
history.push({
year: worldState.currentYear,
month: worldState.currentMonth,
refresh: currentRefresh,
online: currentOnline,
date: new Date().toISOString(),
});
return {
history,
maxRefresh: Math.max(
1,
readFiniteMetaNumber(meta, 'maxrefresh'),
...history.map((entry) => entry.refresh)
),
maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)),
suspects: [
{
generalId: null,
name: '접속자 총합',
refresh: totalRefresh,
refreshScoreTotal: totalRefreshScore,
},
...topAccess.map((entry) => ({
generalId: entry.generalId,
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
refresh: entry.refresh,
refreshScoreTotal: entry.refreshScoreTotal,
})),
],
};
}),
getGeneralList: procedure.query(async ({ ctx }) => {
const [generals, nations] = await Promise.all([
ctx.db.general.findMany({
@@ -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,209 @@
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:00.000Z');
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'user-1',
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: 900,
dedication: 100,
officerLevel: 1,
gold: 1_000,
rice: 2_000,
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 auth = (userId: string): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
sessionId: `session-${userId}`,
user: { id: userId, username: userId, displayName: userId, roles: [] },
sanctions: {},
});
const createContext = (options: {
sessionUserId?: string;
generals?: GeneralRow[];
nationMeta?: Record<string, unknown>;
}) => {
const sessionUserId = options.sessionUserId ?? 'user-1';
const generals = options.generals ?? [buildGeneral()];
const db = {
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
generals.find((general) => general.userId === where.userId)
),
findMany: vi.fn(async ({ where }: { where: { nationId: number } }) =>
generals.filter((general) => general.nationId === where.nationId)
),
},
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '위',
color: '#008000',
level: 3,
typeCode: 'che_중립',
capitalCityId: 1,
meta: options.nationMeta ?? { secretlimit: 3 },
})),
},
city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) },
troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) },
generalAccessLog: {
findMany: vi.fn(async () =>
generals.map((general) => ({
generalId: general.id,
refreshScore: general.id,
refreshScoreTotal: general.id * 10,
}))
),
},
generalTurn: {
findMany: vi.fn(async () => [
{ generalId: 1, turnIdx: 0, actionCode: '징병' },
{ generalId: 1, turnIdx: 1, actionCode: '훈련' },
]),
},
};
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
return {
context: {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: auth(sessionUserId),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
} satisfies GameApiContext,
db,
};
};
describe('nation general and secret-office permissions', () => {
it('redacts confidential columns for an ordinary member and denies the secret office', async () => {
const fixture = createContext({});
const caller = appRouter.createCaller(fixture.context);
const list = await caller.nation.getGeneralList();
expect(list.viewer).toEqual({ generalId: 1, permission: 0 });
expect(list.generals[0]).toMatchObject({
officerLevel: 1,
gold: 1_000,
rice: 2_000,
detail: null,
});
expect(fixture.db.generalTurn.findMany).not.toHaveBeenCalled();
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('allows a tenured member, scopes rows to the actor nation, and returns legacy summary and turns', async () => {
const me = buildGeneral({ meta: { belong: 3, defence_train: 90, killturn: 7 } });
const ally = buildGeneral({
id: 2,
userId: 'user-2',
name: '아군',
troopId: 2,
gold: 3_000,
rice: 4_000,
crew: 200,
train: 80,
atmos: 80,
});
const hiddenNpc = buildGeneral({ id: 3, userId: null, name: '가상', npcState: 5, gold: 99_999 });
const foreign = buildGeneral({ id: 4, userId: 'foreign', nationId: 2, gold: 88_888 });
const fixture = createContext({ generals: [me, ally, hiddenNpc, foreign] });
const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList();
expect(result.viewer.permission).toBe(1);
expect(result.generals.map((general) => general.id)).toEqual([1, 2, 3]);
expect(result.generals[0]?.detail).toMatchObject({
cityName: '업',
defenceTrain: 90,
killTurn: 7,
reservedCommands: ['징병', '훈련'],
});
expect(result.generals[1]?.detail?.troopName).toBe('선봉대');
expect(result.summary).toMatchObject({
gold: 4_000,
rice: 6_000,
crew: 500,
generalCount: 2,
readiness: {
90: { crew: 300, generals: 1 },
80: { crew: 500, generals: 2 },
60: { crew: 500, generals: 2 },
},
});
expect(fixture.db.general.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { nationId: 1 } })
);
});
it('derives the acting general from the authenticated user and applies that general permission', async () => {
const first = buildGeneral({ userId: 'user-1', meta: { belong: 1 } });
const second = buildGeneral({ id: 2, userId: 'user-2', officerLevel: 5, meta: { belong: 1 } });
const fixture = createContext({ sessionUserId: 'user-2', generals: [first, second] });
const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList();
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-2' } });
});
it('keeps penalty-based secret denial even for an otherwise qualified general', async () => {
const me = buildGeneral({ officerLevel: 5, penalty: { noChief: true } });
const fixture = createContext({ generals: [me] });
await expect(appRouter.createCaller(fixture.context).nation.getSecretGeneralList()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const buildContext = (): GameApiContext => {
const db = {
worldState: {
findFirst: async () => ({
id: 1,
currentYear: 185,
currentMonth: 3,
tickSeconds: 600,
config: {},
meta: {
lastTurnTime: '2026-07-26T03:00:00.000Z',
refresh: 12,
maxrefresh: 30,
maxonline: 5,
recentTraffic: [
{
year: 185,
month: 2,
refresh: 30,
online: 5,
date: '2026-07-26 02:50:00',
},
],
},
}),
},
generalAccessLog: {
aggregate: async () => ({
_sum: {
refresh: 12,
refreshScoreTotal: 21,
},
}),
count: async (args: { where: { lastRefresh: { gte: Date } } }) => {
expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z'));
return 2;
},
findMany: async () => [
{ generalId: 7, refresh: 9, refreshScoreTotal: 15 },
{ generalId: 8, refresh: 3, refreshScoreTotal: 6 },
],
},
general: {
findMany: async () => [
{ id: 7, name: '갑' },
{ id: 8, name: '을' },
],
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('public.getTraffic', () => {
it('is public and returns only aggregate traffic plus allowlisted general names', async () => {
const result = await appRouter.createCaller(buildContext()).public.getTraffic();
expect(result.history).toHaveLength(2);
expect(result.history[0]).toEqual({
year: 185,
month: 2,
refresh: 30,
online: 5,
date: '2026-07-26 02:50:00',
});
expect(result.history[1]).toMatchObject({
year: 185,
month: 3,
refresh: 12,
online: 2,
});
expect(result.maxRefresh).toBe(30);
expect(result.maxOnline).toBe(5);
expect(result.suspects).toEqual([
{ generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 },
{ generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 },
{ generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 },
]);
expect(JSON.stringify(result)).not.toContain('userId');
});
});
@@ -809,9 +809,35 @@ async function handleVacation(
if (!general) {
return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
}
const autorunUser = asRecord(world.getState().meta.autorun_user);
if (autorunUser.limit_minutes) {
return {
type: 'vacation',
ok: false,
generalId: command.generalId,
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
};
}
const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0);
world.updateGeneral(general.id, {
meta: {
...general.meta,
killturn: killturn * 3,
},
});
return { type: 'vacation', ok: true, generalId: command.generalId };
}
const normalizeDefenceTrain = (value: number): number => {
if (value <= 40) {
return 40;
}
if (value <= 90) {
return Math.round(value / 10) * 10;
}
return 999;
};
async function handleSetMySetting(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
@@ -826,11 +852,48 @@ async function handleSetMySetting(
reason: '장수 정보를 찾을 수 없습니다.',
};
}
const settings = command.settings;
const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80);
const nextDefenceTrain =
settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train);
const nextMeta = { ...general.meta };
if (settings.tnmt !== undefined) {
nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt;
}
if (settings.use_treatment !== undefined) {
nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment));
}
if (settings.use_auto_nation_turn !== undefined) {
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn;
}
let nextTrain = general.train;
let nextAtmos = general.atmos;
if (nextDefenceTrain !== previousDefenceTrain) {
nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1;
nextMeta.defence_train = nextDefenceTrain;
if (nextDefenceTrain === 999) {
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
const ignoresPenalty =
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
scenarioEffect === 'event_StrongAttacker' ||
scenarioEffect === 'event_MoreEffect';
const constValues = asRecord(world.getScenarioConfig().const);
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
const trainDelta = ignoresPenalty ? 0 : -3;
const atmosDelta = ignoresPenalty ? 0 : -6;
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta));
}
}
world.updateGeneral(command.generalId, {
meta: {
...general.meta,
...command.settings,
},
meta: nextMeta,
train: nextTrain,
atmos: nextAtmos,
});
return { type: 'setMySetting', ok: true, generalId: command.generalId };
}
@@ -844,10 +907,8 @@ async function handleDropItem(
if (!general) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
}
const slot = (['horse', 'weapon', 'book', 'item'] as const).find(
(candidate) => general.role.items[candidate] === command.itemType
);
if (!slot) {
const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType);
if (!slot || !general.role.items[slot]) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
}
const nextGeneral = {
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id: 7,
userId: 'user-7',
name: '테스트장수',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
turnTime: new Date('0185-01-01T00:00:00Z'),
recentWarTime: null,
role: {
items: { horse: 'che_명마', weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
killturn: 12,
myset: 3,
defence_train: 80,
tnmt: 0,
use_treatment: 10,
use_auto_nation_turn: 1,
},
penalty: {},
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 0,
train: 90,
atmos: 90,
age: 20,
npcState: 0,
...overrides,
});
const buildWorld = (
general = buildGeneral(),
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
) => {
const state: TurnWorldState = {
id: 1,
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
meta: {
killturn: 24,
autorun_user: options.autorunLimit ? { limit_minutes: 60 } : {},
},
};
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: { maxTrainByWar: 100, maxAtmosByWar: 100 },
environment: {
mapName: 'test',
unitSet: 'test',
...(options.scenarioEffect !== undefined ? { scenarioEffect: options.scenarioEffect } : {}),
},
},
scenarioMeta: {
title: 'test',
startYear: 180,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
return { world, handler: createTurnDaemonCommandHandler({ world }) };
};
describe('my information world commands', () => {
it('normalizes legacy settings and charges myset only when defence mode changes', async () => {
const fixture = buildWorld();
await expect(
fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: {
tnmt: 9,
defence_train: 94,
use_treatment: 200,
use_auto_nation_turn: 0,
},
})
).resolves.toMatchObject({ ok: true });
expect(fixture.world.getGeneralById(7)).toMatchObject({
train: 87,
atmos: 84,
meta: {
tnmt: 1,
defence_train: 999,
use_treatment: 100,
use_auto_nation_turn: 0,
myset: 2,
},
});
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { tnmt: 0, defence_train: 999, use_treatment: 1 },
});
expect(fixture.world.getGeneralById(7)?.meta).toMatchObject({
tnmt: 0,
use_treatment: 10,
myset: 2,
});
});
it('preserves the event scenarios that waive the no-defence penalty', async () => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { defence_train: 999 },
});
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
});
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
const allowed = buildWorld();
await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true });
expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72);
const blocked = buildWorld(buildGeneral(), { autorunLimit: true });
await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({
ok: false,
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
});
expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12);
});
it('drops only the authenticated command target slot and rejects an empty slot', async () => {
const fixture = buildWorld();
await expect(
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' })
).resolves.toMatchObject({ ok: false });
await expect(
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' })
).resolves.toMatchObject({ ok: true });
expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull();
});
});
+314
View File
@@ -0,0 +1,314 @@
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 === '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 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('권한이 부족합니다.');
});
+1 -1
View File
@@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`;
export default defineConfig({
testDir: '.',
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'],
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 30_000,
+17 -3
View File
@@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue';
import GlobalInfoView from '../views/GlobalInfoView.vue';
import CurrentCityView from '../views/CurrentCityView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationSecretView from '../views/NationSecretView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
import NationStratFinanView from '../views/NationStratFinanView.vue';
import ChiefCenterView from '../views/ChiefCenterView.vue';
@@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue';
import BettingView from '../views/BettingView.vue';
import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import BoardView from '../views/BoardView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
@@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue';
import YearbookView from '../views/YearbookView.vue';
import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue';
import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -148,6 +149,15 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/nation/secret',
name: 'nation-secret',
component: NationSecretView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/nation/personnel',
name: 'nation-personnel',
@@ -251,6 +261,11 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/traffic',
name: 'traffic',
component: TrafficView,
},
{
path: '/npc-list',
name: 'npc-list',
@@ -276,8 +291,7 @@ const routes = [
},
{
path: '/my-settings',
name: 'my-settings',
component: MySettingsView,
redirect: '/my-page',
meta: {
requiresAuth: true,
requiresGeneral: true,
+180 -56
View File
@@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
@@ -269,7 +268,26 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<GeneralBasicCard :general="selectedGeneral" :loading="loading" />
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
</div>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
</div>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -286,12 +304,7 @@ onMounted(() => {
<SkeletonLines v-if="loading || logLoading" :lines="3" />
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[type]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
@@ -303,126 +316,237 @@ onMounted(() => {
<style scoped>
.battle-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
min-height: 100vh;
padding: 24px;
margin: 0 auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
color: #fff;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.5;
}
.page-header {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 0 8px;
border: 1px solid #666;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
font-size: 17px;
font-weight: 500;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
display: none;
}
.header-actions {
position: absolute;
left: 0;
top: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 4px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
display: contents;
}
.selector-row {
display: grid;
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto;
gap: 8px;
grid-template-columns: 8.333% 33.333% 50% 8.333%;
gap: 0;
align-items: center;
}
.select-input {
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
height: 36px;
padding: 4px 6px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
font-size: 0.85rem;
font: inherit;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
min-height: 32px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
padding: 4px 8px;
font: inherit;
cursor: pointer;
}
.general-meta {
margin-top: 10px;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.75);
margin: 0;
padding: 6px 8px;
color: #ccc;
display: grid;
gap: 4px;
}
.log-grid {
.battle-general-card {
min-height: 292px;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid #777;
background: rgba(220, 220, 220, 0.85);
color: #111;
font-weight: 700;
}
.battle-general-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-grid > * {
min-height: 24px;
padding: 2px 5px;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
}
.battle-general-grid > span {
background-color: rgba(20, 75, 42, 0.7);
color: #fff;
text-align: center;
}
.battle-general-grid > strong {
text-align: right;
font-weight: 500;
}
.log-grid {
display: contents;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
border: 1px solid #666;
padding: 0;
background: #111;
min-height: 180px;
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
color: orange;
background: #252525;
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
}
.log-line:last-child {
border-bottom: none;
padding: 2px 8px;
border-bottom: 0;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
padding: 2px 8px;
color: #999;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
padding: 5px 8px;
color: #ff7777;
border: 1px solid #a33;
text-align: center;
}
@media (max-width: 1024px) {
/* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */
:deep(.panel-card) {
height: 100%;
border: 1px solid #666;
border-radius: 0;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
box-shadow: none;
}
.stack:first-child :deep(.panel-card:first-child) {
grid-column: 1 / -1;
border: 0;
}
.stack:first-child :deep(.panel-card:first-child .panel-header) {
display: none;
}
.stack:first-child :deep(.panel-card:first-child .panel-body) {
padding: 0;
}
.stack:nth-child(2) :deep(.panel-card),
.stack:nth-child(2) :deep(.panel-body) {
display: contents;
}
.stack:nth-child(2) :deep(.panel-header) {
display: none;
}
:deep(.panel-header) {
min-height: 29px;
justify-content: center;
padding: 0;
}
:deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
:deep(.panel-header),
.log-title {
background-image: url('/image/game/back_green.jpg');
}
@media (max-width: 991px) {
.battle-page {
width: 500px;
}
.layout-grid {
grid-template-columns: 1fr;
}
.selector-row {
grid-template-columns: 16.666% 25% 41.666% 16.666%;
}
.log-grid {
grid-template-columns: 1fr;
}
}
+6 -1
View File
@@ -104,6 +104,10 @@ watch(
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="ghost" to="/nation/secret"
>암행부</RouterLink
>
<span v-else class="ghost disabled" aria-disabled="true">암행부</span>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
@@ -115,10 +119,11 @@ watch(
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
+543 -467
View File
@@ -1,19 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
type WorldSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
meta: Record<string, unknown>;
} | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type LogLine = {
id: number;
html: string;
};
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type ItemSlot = {
key: ItemSlotKey;
label: string;
code: string | null;
};
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const logs = reactive<Record<LogType, LogLine[]>>({
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
const logLabels: Record<LogType, string> = {
generalAction: '개인 기록',
battleDetail: '전투 기록',
generalHistory: '장수 열전',
battleResult: '전투 결과',
};
const logColors: Record<LogType, string> = {
generalAction: 'skyblue',
battleDetail: 'orange',
generalHistory: 'skyblue',
battleResult: 'orange',
};
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
generalHistory: [],
battleDetail: [],
battleResult: [],
generalAction: [],
});
const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false,
battleDetail: false,
battleResult: false,
generalAction: false,
});
const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true,
battleDetail: true,
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
generalAction: true,
});
const activeLogTab = ref<LogType>('generalAction');
const isMobile = useMediaQuery('(max-width: 1024px)');
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const numberValue = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const statusLine = computed(() =>
world.value
? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
1,
Math.round(world.value.tickSeconds / 60)
)}분 턴`
: '내 정보를 불러오는 중'
);
const statusLine = computed(() => {
if (!worldState.value) {
return '내 정보를 불러오는 중';
}
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : '';
return `${worldState.value.currentYear}${worldState.value.currentMonth}${termLabel}`;
});
const itemSlots = computed<ItemSlot[]>(() => {
const items = data.value?.general?.items;
return [
{ key: 'horse', label: '말', code: items?.horse ?? null },
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
{ key: 'book', label: '서적', code: items?.book ?? null },
{ key: 'item', label: '아이템', code: items?.item ?? null },
];
});
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
]);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
const showVacation = computed(() => !autorunUser.value.limit_minutes);
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
const npcMode = resolveNumber(config.npcMode, 0);
const meta = world.value?.meta ?? {};
const config = world.value?.config ?? {};
const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false),
canInstantRetreat: Boolean(general && general.nationId > 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
instantRetreat: Boolean(availableInstantAction.instantRetreat),
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const loadLogs = async () => {
if (!data.value?.general?.id) {
return;
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
await Promise.all(
logTypes.map((type) => loadLog(type))
);
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) {
return;
}
if (logLoading[type]) return;
logLoading[type] = true;
try {
const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
logs[type] = beforeId ? [...logs[type], ...next] : next;
logHasMore[type] = next.length >= 24;
} catch (cause) {
error.value = errorText(cause);
} finally {
logLoading[type] = false;
}
};
const loadMyPage = async () => {
if (loading.value) {
return;
}
const loadPage = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
const [general, state] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
]);
data.value = general;
worldState.value = world ?? null;
await loadLogs();
} catch (err) {
error.value = resolveErrorMessage(err);
world.value = state;
if (general) {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
} catch (cause) {
error.value = errorText(cause);
} finally {
loading.value = false;
}
};
const confirmAction = async (message: string, action: () => Promise<void>) => {
if (!confirm(message)) {
return;
}
const saveSettings = async () => {
if (!canSave.value) return;
try {
await action();
await loadMyPage();
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
await trpc.general.setMySetting.mutate({ ...form });
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
const handleDieOnPrestart = () =>
confirmAction('정말로 삭제하시겠습니까?', async () => {
await trpc.general.dieOnPrestart.mutate();
window.location.reload();
});
const handleBuildNationCandidate = () =>
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
await trpc.general.buildNationCandidate.mutate();
});
const handleInstantRetreat = () =>
confirmAction('아군 접경으로 이동할까요?', async () => {
await trpc.general.instantRetreat.mutate();
});
const handleVacation = () =>
confirmAction('휴가 기능을 신청할까요?', async () => {
await trpc.general.vacation.mutate();
});
const handleDropItem = (slot: ItemSlot) =>
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
await trpc.general.dropItem.mutate({ itemType: slot.key });
});
const refreshScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
if (mode === '500px' || mode === '1000px') {
screenMode.value = mode;
} else {
screenMode.value = 'auto';
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
watch(
() => isMobile.value,
(value) => {
if (value && !logTypes.includes(activeLogTab.value)) {
activeLogTab.value = 'generalAction';
}
}
);
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
refreshScreenMode();
void loadMyPage();
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main class="my-page" :class="`screen-${screenMode}`">
<header class="page-header">
<div>
<h1 class="page-title"> 정보</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
<button class="ghost" @click="loadMyPage">새로고침</button>
</div>
</header>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="장수 상태">
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="도시 상태">
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="세력 상태">
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
</PanelCard>
</div>
<div class="stack">
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
<div class="action-grid">
<button
v-if="actionAvailability.canVacation"
class="action-btn"
type="button"
@click="handleVacation"
>
휴가 신청
</button>
<button
v-if="actionAvailability.canDieOnPrestart"
class="action-btn"
type="button"
@click="handleDieOnPrestart"
>
장수 삭제
</button>
<button
v-if="actionAvailability.canBuildNationCandidate"
class="action-btn"
type="button"
@click="handleBuildNationCandidate"
>
사전 거병
</button>
<button
v-if="actionAvailability.canInstantRetreat"
class="action-btn"
type="button"
@click="handleInstantRetreat"
>
접경 귀환
</button>
<RouterLink
v-if="actionAvailability.canSelectOtherGeneral"
class="action-btn link"
to="/join"
>
다른 장수 선택
</RouterLink>
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<img
:src="
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
"
alt=""
/>
<strong>{{ data.general.name }}</strong>
</div>
</PanelCard>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
<div class="item-grid">
<button
v-for="slot in itemSlots"
:key="slot.key"
class="item-btn"
type="button"
:disabled="!slot.code"
@click="handleDropItem(slot)"
>
{{ slot.label }}: {{ slot.code ?? '-' }}
</button>
</div>
</PanelCard>
<PanelCard v-if="isMobile" title="장수 기록">
<div class="log-tabs">
<button
v-for="type in logTypes"
:key="type"
:class="{ active: activeLogTab === type }"
@click="activeLogTab = type"
>
{{ logLabels[type] }}
</button>
</div>
<div class="log-block">
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[activeLogTab]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<button
v-if="logHasMore[activeLogTab]"
class="ghost log-more"
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</PanelCard>
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
<div class="log-grid">
<div v-for="type in logTypes" :key="type" class="log-block">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<button
v-if="logHasMore[type]"
class="ghost log-more"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
</div>
</div>
</PanelCard>
<div>
<dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
</dl>
</div>
</div>
<div class="settings-column">
<div class="setting-line">
토너먼트
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
</div>
<div class="hint"> 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
<label class="setting-line">
환약 사용
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용안함</option>
</select>
</label>
<div class="hint"> 부상을 입었을 환약을 사용하는 기준입니다.</div>
<label v-if="showAutoNationTurn" class="setting-line">
자동 사령턴 허용
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
</select>
</label>
<button
id="set_my_setting"
class="action-button"
type="button"
:hidden="!canSave"
@click="saveSettings"
>
설정저장
</button>
<div class="hint"> 설정저장은 이달중 {{ data?.settings.myset ?? 0 }} 남았습니다.</div>
<div v-if="penalties.length" class="penalties">
징계 목록(저장 갱신)
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
</div>
<div v-if="showVacation" class="action-line">
<br />
<button
class="action-button"
type="button"
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
>
휴가 신청
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제<br />
<button
class="action-button"
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
>
장수 삭제
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate()
)
"
>
사전 거병
</button>
</div>
<div v-if="actionAvailability.instantRetreat" class="action-line">
거리 3칸 이내 아국 도시로 즉시 이동<br />
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
"
>
접경 귀환
</button>
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
v-for="item in items"
:key="item.key"
type="button"
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.code ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<button
v-if="logHasMore[type]"
class="load-old"
type="button"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.my-page {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: #111;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.3;
}
.my-page.screen-500px {
.legacy-page.screen-500px {
max-width: 500px;
}
.my-page.screen-1000px {
.legacy-page.screen-1000px {
max-width: 1000px;
}
.page-header {
.title-row {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
}
.page-title {
font-size: 1.6rem;
.title-row > span {
flex-basis: 100%;
height: 18px;
letter-spacing: 0;
}
.legacy-button,
button,
select,
textarea {
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #6b6b6b;
font: inherit;
}
.legacy-button {
min-height: 34px;
padding: 5px 10px;
border-color: #2d5d7f;
border-radius: 4px;
background: #315f86;
color: #fff;
font-weight: 700;
text-decoration: none;
letter-spacing: 0;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.action-btn {
border: 1px solid rgba(201, 164, 90, 0.5);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center;
}
.action-btn.link {
display: inline-flex;
align-items: center;
justify-content: center;
.error-row {
color: #ff7777;
border: 1px solid #a33;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.item-btn {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 10px;
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.item-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.top-grid,
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
.general-column,
.settings-column,
.log-panel {
border: 1px solid #666;
background-image: url('/image/game/back_walnut.jpg');
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
.section-title,
.log-panel h2 {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
font-size: 1.25em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
.sky {
color: skyblue;
}
.log-line:last-child {
border-bottom: none;
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.log-more {
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-cell img {
width: 64px;
height: 64px;
object-fit: cover;
}
dl {
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: 80px 1fr;
border-bottom: 1px solid #777;
}
dt,
dd {
margin: 0;
padding: 2px 5px;
border-right: 1px solid #777;
}
dt {
color: #aaa;
}
.settings-column {
padding: 10px 18px;
}
.setting-line {
display: block;
margin-top: 5px;
}
.hint {
margin: 0 0 13px;
color: orange;
}
.action-button {
width: 160px;
height: 30px;
margin: 4px 0;
background: #225500;
}
.action-line {
margin: 12px 0;
}
.penalties {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
.item-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 5px 0 14px;
}
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
.log-panel h2 {
color: orange;
}
.log-line,
.empty,
.loading {
padding: 2px 8px;
}
.load-old {
width: 100%;
min-height: 32px;
margin-top: 8px;
}
.log-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.log-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.log-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
@media (max-width: 991px) {
.legacy-page {
width: 500px;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
+225 -313
View File
@@ -1,349 +1,261 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type GeneralListResponse = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
type GeneralEntry = GeneralListResponse['generals'][number];
type SortKey =
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 13
| 14
| 15;
const sortOptions: Array<{ key: SortKey; label: string }> = [
{ key: 1, label: '관직' },
{ key: 2, label: '공헌' },
{ key: 3, label: '경험' },
{ key: 4, label: '통솔' },
{ key: 5, label: '무력' },
{ key: 6, label: '지력' },
{ key: 7, label: '자금' },
{ key: 8, label: '군량' },
{ key: 9, label: '병사' },
{ key: 10, label: '벌점' },
{ key: 11, label: '성격' },
{ key: 12, label: '내특' },
{ key: 13, label: '전특' },
{ key: 14, label: '사관' },
{ key: 15, label: 'NPC' },
const data = ref<Result | null>(null);
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(1);
const sortOptions = [
'관직',
'계급',
'명성',
'통솔',
'무력',
'지력',
'자금',
'군량',
'병사',
'벌점',
'성격',
'내특',
'전특',
'사관',
'NPC',
];
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<GeneralListResponse | null>(null);
const sortKey = ref<SortKey>(1);
const filterText = ref('');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const loadGenerals = async () => {
if (loading.value) {
return;
}
const load = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
error.value = '';
try {
data.value = await trpc.nation.getGeneralList.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const sortGenerals = (list: GeneralEntry[]): GeneralEntry[] => {
const key = sortKey.value;
const sorted = [...list].sort((lhs, rhs) => {
switch (key) {
case 1:
return rhs.officerLevel - lhs.officerLevel;
case 2:
return rhs.dedication - lhs.dedication;
case 3:
return rhs.experience - lhs.experience;
case 4:
return rhs.stats.leadership - lhs.stats.leadership;
case 5:
return rhs.stats.strength - lhs.stats.strength;
case 6:
return rhs.stats.intelligence - lhs.stats.intelligence;
case 7:
return rhs.gold - lhs.gold;
case 8:
return rhs.rice - lhs.rice;
case 9:
return rhs.crew - lhs.crew;
case 10:
return 0;
case 11:
return (lhs.personality?.name ?? '').localeCompare(rhs.personality?.name ?? '');
case 12:
return (lhs.specialDomestic?.name ?? '').localeCompare(rhs.specialDomestic?.name ?? '');
case 13:
return (lhs.specialWar?.name ?? '').localeCompare(rhs.specialWar?.name ?? '');
case 14:
return rhs.belong - lhs.belong;
case 15:
return rhs.npcState - lhs.npcState;
default:
return 0;
}
});
const sortedGenerals = computed(() =>
[...(data.value?.generals ?? [])].sort((left, right) => {
const key = sort.value;
if (key === 1) return right.officerLevel - left.officerLevel || left.id - right.id;
if (key === 2) return right.dedicationLevel - left.dedicationLevel || left.id - right.id;
if (key === 3) return right.experienceLevel - left.experienceLevel || left.id - right.id;
if (key === 4) return right.stats.leadership - left.stats.leadership || left.id - right.id;
if (key === 5) return right.stats.strength - left.stats.strength || left.id - right.id;
if (key === 6) return right.stats.intelligence - left.stats.intelligence || left.id - right.id;
if (key === 7) return right.gold - left.gold || left.id - right.id;
if (key === 8) return right.rice - left.rice || left.id - right.id;
if (key === 9) return (right.detail?.crew ?? -1) - (left.detail?.crew ?? -1) || left.id - right.id;
if (key === 10) return right.refreshScoreTotal - left.refreshScoreTotal || left.id - right.id;
if (key === 11) return (left.personality?.name ?? '').localeCompare(right.personality?.name ?? '');
if (key === 12) return (left.specialDomestic?.name ?? '').localeCompare(right.specialDomestic?.name ?? '');
if (key === 13) return (left.specialWar?.name ?? '').localeCompare(right.specialWar?.name ?? '');
if (key === 14) return right.belong - left.belong || left.id - right.id;
return right.npcState - left.npcState || left.id - right.id;
})
);
if (key === 11 || key === 12 || key === 13) {
return sorted;
}
return sorted;
const imageUrl = (general: General): string => {
const picture = general.picture || 'default.jpg';
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
const specialText = (general: General): string =>
`${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
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();
});
onMounted(load);
</script>
<template>
<main class="nation-page">
<header class="page-header">
<div>
<h1 class="page-title">세력 장수</h1>
<p class="page-subtitle">세력 장수 현황 정렬</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<button class="ghost" @click="loadGenerals">새로고침</button>
</div>
<main class="general-page legacy-bg0">
<header class="top-bar">
<strong>세력 장수</strong>
<span class="toolbar">
<RouterLink to="/">돌아가기</RouterLink>
<button type="button" :disabled="loading" @click="load">새로고침</button>
</span>
</header>
<div v-if="error" class="error">{{ error }}</div>
<PanelCard title="세력 장수 목록" subtitle="국가 소속 장수들을 확인합니다.">
<template #actions>
<div class="toolbar-actions">
<select v-model.number="sortKey" class="select-input">
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</select>
<input v-model="filterText" class="filter-input" placeholder="이름/도시 검색" />
</div>
</template>
<div class="list-meta"> {{ filteredGenerals.length }}</div>
<SkeletonLines v-if="loading" :lines="6" />
<div v-else class="table-scroll">
<table class="nation-table">
<thead>
<tr>
<th>이름</th>
<th>관직</th>
<th>공헌</th>
<th>경험</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th>자금</th>
<th>군량</th>
<th>병사</th>
<th>성격</th>
<th>특기</th>
<th>사관</th>
<th>현재 도시</th>
<th>관직 도시</th>
</tr>
</thead>
<tbody>
<tr v-for="general in filteredGenerals" :key="general.id">
<td>
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
{{ general.name }}
</td>
<td>{{ formatOfficerLevelText(general.officerLevel, nationLevel) }}</td>
<td>{{ general.dedication }}</td>
<td>{{ general.experience }}</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.personality?.name ?? '-' }}</td>
<td>{{ formatSpecial(general) }}</td>
<td>{{ general.belong > 0 ? general.belong : '-' }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.officerCityName ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
</PanelCard>
<section class="sort-bar">
정렬순서 :
<select v-model.number="sort" aria-label="세력 장수 정렬">
<option v-for="(label, index) in sortOptions" :key="label" :value="index + 1">{{ label }}</option>
</select>
<button type="button">정렬하기</button>
<span v-if="data" class="permission">열람 등급 {{ data.viewer.permission }}</span>
</section>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="grid-scroll">
<table id="nation-general-list" class="general-table">
<thead>
<tr>
<th class="icon-column">아이콘</th>
<th class="name-column">장수</th>
<th>관직</th>
<th>||</th>
<th>명성/계급</th>
<th>/</th>
<th>도시</th>
<th>부대</th>
<th>병사</th>
<th>성격</th>
<th>특기</th>
<th>사관</th>
<th>벌점</th>
</tr>
</thead>
<tbody>
<tr v-for="general in sortedGenerals" :key="general.id">
<td class="icon"><img :src="imageUrl(general)" width="64" height="64" alt="" /></td>
<td class="name" :class="`npc-${general.npcState}`">
<RouterLink :to="`/battle-center?generalId=${general.id}`">{{ general.name }}</RouterLink>
</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.leadership }}</span
><span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span>
| <span :class="{ wounded: general.injury > 0 }">{{ general.stats.strength }}</span> |
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.intelligence }}</span>
</td>
<td>Lv {{ general.experienceLevel }}<br />{{ general.dedicationLevelText }}</td>
<td>{{ general.gold.toLocaleString() }}<br />{{ general.rice.toLocaleString() }}</td>
<td>{{ general.detail?.cityName ?? '?' }}</td>
<td>{{ general.detail?.troopName ?? (general.detail ? '-' : '?') }}</td>
<td>{{ general.detail?.crew.toLocaleString() ?? '?' }}</td>
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
<td
:title="[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\\n')"
>
{{ specialText(general) }}
</td>
<td>{{ general.belong }}</td>
<td>{{ general.refreshScoreTotal }}</td>
</tr>
</tbody>
</table>
</div>
<footer><RouterLink to="/">돌아가기</RouterLink></footer>
</main>
</template>
<style scoped>
.nation-page {
.general-page {
width: 1000px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
margin: 0 auto;
color: #fff;
font-size: 14px;
}
.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);
}
.top-bar,
.sort-bar,
footer,
.state,
.error {
color: #f5b7b1;
font-size: 0.85rem;
border: 1px solid #777;
padding: 4px;
text-align: center;
}
.toolbar-actions {
.top-bar {
position: relative;
min-height: 39px;
display: flex;
align-items: center;
gap: 8px;
}
.select-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.filter-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.list-meta {
margin-bottom: 8px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.table-scroll {
overflow-x: auto;
max-height: 70vh;
overflow-y: auto;
}
.nation-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.nation-table th,
.nation-table td {
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
text-align: left;
white-space: nowrap;
}
.nation-table thead th {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.npc-tag {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
padding: 2px 4px;
}
.toolbar {
position: absolute;
right: 6px;
display: flex;
gap: 8px;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.permission {
float: right;
margin-right: 6px;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
color: #ccc;
}
.grid-scroll {
width: 100%;
min-height: calc(100vh - 116px);
overflow: auto;
}
.general-table {
width: 100%;
min-width: 1000px;
border-collapse: collapse;
table-layout: fixed;
}
.general-table th,
.general-table td {
border: 1px solid #777;
padding: 2px 3px;
text-align: center;
overflow-wrap: anywhere;
}
.general-table th {
height: 30px;
background: #14241b url('/image/game/back_green.jpg');
font-weight: 400;
}
.general-table tbody tr {
height: 68px;
background: rgba(0, 0, 0, 0.18);
}
.icon-column {
width: 70px;
}
.name-column {
width: 88px;
}
.icon {
padding: 1px !important;
}
.icon img {
display: block;
margin: auto;
object-fit: cover;
}
.name a {
color: inherit;
}
.npc-1 {
color: #0ff;
}
.npc-2,
.npc-3,
.npc-4,
.npc-5 {
color: #aaa;
}
.wounded {
color: red;
}
.bonus {
color: cyan;
}
.error {
color: #ff7373;
}
footer {
min-height: 25px;
}
@media (max-width: 1000px) {
.general-page {
margin: 0;
}
}
</style>
@@ -0,0 +1,250 @@
<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 General = Result['generals'][number];
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 sortOptions = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
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((left, right) => {
const leftDetail = left.detail;
const rightDetail = right.detail;
if (!leftDetail || !rightDetail) return left.id - right.id;
if (sort.value === 1) return right.gold - left.gold || left.id - right.id;
if (sort.value === 2) return right.rice - left.rice || left.id - right.id;
if (sort.value === 3) return leftDetail.cityId - rightDetail.cityId || left.id - right.id;
if (sort.value === 4) return rightDetail.crewTypeId - leftDetail.crewTypeId || left.id - right.id;
if (sort.value === 5) return rightDetail.crew - leftDetail.crew || left.id - right.id;
if (sort.value === 6) return leftDetail.killTurn - rightDetail.killTurn || left.id - right.id;
if (sort.value === 7) return leftDetail.turnTime.localeCompare(rightDetail.turnTime) || left.id - right.id;
return rightDetail.troopId - leftDetail.troopId || left.id - right.id;
})
);
const turnTime = (general: General): string => {
const value = general.detail?.turnTime;
return value ? value.slice(11, 16) : '-';
};
onMounted(load);
</script>
<template>
<main class="secret-page">
<table class="layout-table 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 sortOptions" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<button type="button">정렬하기</button>
<button type="button" :disabled="loading" @click="load">새로고침</button>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="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-table 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.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</td>
<th>평균 </th>
<td>{{ data.summary.averageRice.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</td>
</tr>
<tr>
<th>전체 병력/장수</th>
<td>{{ data.summary.crew.toLocaleString() }}/{{ data.summary.generalCount }}</td>
<th>훈사 90 병력/장수</th>
<td>
{{ data.summary.readiness[90].crew.toLocaleString() }}/{{
data.summary.readiness[90].generals
}}
</td>
<th>훈사 80 병력/장수</th>
<td>
{{ data.summary.readiness[80].crew.toLocaleString() }}/{{
data.summary.readiness[80].generals
}}
</td>
<th>훈사 60 병력/장수</th>
<td>
{{ data.summary.readiness[60].crew.toLocaleString() }}/{{
data.summary.readiness[60].generals
}}
</td>
</tr>
</tbody>
</table>
<table id="secret-general-list" class="layout-table general-list legacy-bg0">
<thead>
<tr>
<th class="name"> </th>
<th class="stat">통무지</th>
<th class="troop"> </th>
<th> </th>
<th> </th>
<th>도시</th>
<th class="mode"></th>
<th> </th>
<th> </th>
<th>훈련</th>
<th>사기</th>
<th class="turns"> </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>
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.leadership }}</span
><span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span
><span :class="{ wounded: general.injury > 0 }">{{ general.stats.strength }}</span
><span :class="{ wounded: general.injury > 0 }">{{ general.stats.intelligence }}</span>
</td>
<td>{{ general.detail?.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.detail?.cityName ?? '-' }}</td>
<td>{{ general.defenceTrainText }}</td>
<td>{{ general.detail?.crewTypeId ?? '-' }}</td>
<td>{{ general.detail?.crew ?? '-' }}</td>
<td>{{ general.detail?.train ?? '-' }}</td>
<td>{{ general.detail?.atmos ?? '-' }}</td>
<td class="turn-list">
<template v-if="general.npcState >= 2">NPC 장수</template>
<template v-else>
<div
v-for="(command, index) in general.detail?.reservedCommands ?? []"
:key="`${general.id}-${index}`"
>
{{ index + 1 }} : {{ command }}
</div>
</template>
</td>
<td>{{ general.detail?.killTurn ?? '-' }}</td>
<td>{{ turnTime(general) }}</td>
</tr>
</tbody>
</table>
</template>
<table class="layout-table legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/"> 닫기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.secret-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
color: #fff;
}
.layout-table {
width: 1000px;
border-collapse: collapse;
}
.layout-table td,
.layout-table th,
.state,
.error {
border: 1px solid #777;
padding: 3px;
font-weight: 400;
text-align: center;
}
.title {
text-align: center;
}
.title button,
.title select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.summary {
margin: 5px auto;
}
.summary th,
.general-list th {
background: #14241b url('/image/game/back_green.jpg');
}
.summary th {
width: 120px;
}
.general-list {
table-layout: fixed;
}
.general-list .name,
.general-list .stat,
.general-list .troop {
width: 98px;
}
.general-list .mode {
width: 28px;
}
.general-list .turns {
width: 213px;
}
.general-list tbody tr {
height: 50px;
}
.turn-list {
text-align: left !important;
font-size: 11px;
}
.wounded {
color: red;
}
.bonus {
color: cyan;
}
.error {
color: #ff7373;
}
.footer {
margin-top: 5px;
}
@media (max-width: 1000px) {
.secret-page {
margin: 0;
}
}
</style>
@@ -108,6 +108,7 @@ onMounted(() => {
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<button class="ghost" @click="refreshPublicData">새로고침</button>
</div>
</header>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const load = async () => {
if (loading.value) {
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await trpc.public.getTraffic.query();
} catch (error) {
// Preserve the last successful graph if a later refresh fails.
errorMessage.value = getErrorMessage(error);
} finally {
loading.value = false;
}
};
const refreshRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.refresh,
width: Math.round((entry.refresh / Math.max(1, data.value?.maxRefresh ?? 1)) * 1_000) / 10,
}))
);
const onlineRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.online,
width: Math.round((entry.online / Math.max(1, data.value?.maxOnline ?? 1)) * 1_000) / 10,
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
Math.floor((Math.max(0, Math.min(100, value)) * 255) / 100)
.toString(16)
.padStart(2, '0');
return `#${channel(percentage)}00${channel(100 - percentage)}`;
};
onMounted(() => {
void load();
});
</script>
<template>
<main id="traffic-container" class="traffic-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="traffic-error" role="alert">{{ errorMessage }}</div>
<div v-if="loading && !data" class="traffic-loading">불러오는 중...</div>
<section v-if="data" class="chart-layout">
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in refreshRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxRefresh }}</td></tr>
</tbody>
</table>
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in onlineRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxOnline }}</td></tr>
</tbody>
</table>
</section>
<table v-if="data" class="legacy-table suspect-table legacy-bg0">
<thead>
<tr>
<th colspan="3" class="legacy-bg2 chart-title"> (순간과도갱신)</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in data.suspects" :key="entry.generalId ?? 'total'">
<td class="suspect-name">{{ entry.name }}</td>
<td class="suspect-score">{{ entry.refreshScoreTotal }}({{ entry.refresh }})</td>
<td class="little-bar-cell">
<div
v-if="entry.refresh > 0"
class="little-bar"
:style="{
width: `${Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10}%`,
backgroundColor: trafficColor(
Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10
),
}"
></div>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr><td><RouterLink class="legacy-close" to="/">돌아가기</RouterLink></td></tr>
<tr><td class="banner">SAMMO</td></tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.traffic-page {
width: 1016px;
min-width: 1016px;
min-height: 100vh;
margin: 0 auto;
padding: 0;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: normal;
}
.legacy-table {
border-collapse: collapse;
padding: 0;
color: #fff;
font-size: 14px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid gray;
padding: 0;
text-align: center;
font-weight: 400;
}
.legacy-bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.legacy-bg1 {
background-color: #423226;
background-image: url('/image/game/back_sandal.jpg');
}
.legacy-bg2 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.title-table,
.footer-table {
width: 1000px;
margin: 0 auto;
}
.title-table {
margin-bottom: 18px;
}
.title-table td {
height: 54px;
}
.chart-layout {
width: 1016px;
display: flex;
gap: 26px;
align-items: flex-start;
box-sizing: border-box;
padding: 0 12px;
}
.chart-table {
width: 483px;
table-layout: fixed;
}
.chart-title {
height: 34px;
font-size: 24px;
}
.chart-row {
height: 30px;
}
.period {
width: 100px;
}
.time {
width: 60px;
}
.separator {
width: 2px;
}
.bar-cell {
width: 320px;
text-align: left !important;
white-space: nowrap;
}
.big-bar {
float: left;
position: relative;
height: 30px;
}
.big-bar span {
float: right;
padding-right: 1ch;
line-height: 30px;
}
.out-bar {
line-height: 30px;
margin-left: 1ch;
}
.spacer {
height: 5px;
}
.record {
height: 30px;
}
.suspect-table {
margin: 18px auto;
}
.suspect-name,
.suspect-score {
width: 98px;
}
.little-bar-cell {
width: 798px;
text-align: left !important;
}
.little-bar {
height: 17px;
}
.footer-table {
margin-top: 18px;
}
.banner {
height: 24px;
}
.legacy-close {
color: #fff;
text-decoration: underline;
}
.traffic-error,
.traffic-loading {
width: 1000px;
margin: -12px auto 12px;
border: 1px solid gray;
padding: 6px;
text-align: center;
background: #302016;
}
.traffic-error {
color: #ff8080;
}
</style>
@@ -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();
}