merge: complete in-game information menus
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -809,9 +809,35 @@ async function handleVacation(
|
||||
if (!general) {
|
||||
return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const autorunUser = asRecord(world.getState().meta.autorun_user);
|
||||
if (autorunUser.limit_minutes) {
|
||||
return {
|
||||
type: 'vacation',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
|
||||
};
|
||||
}
|
||||
const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0);
|
||||
world.updateGeneral(general.id, {
|
||||
meta: {
|
||||
...general.meta,
|
||||
killturn: killturn * 3,
|
||||
},
|
||||
});
|
||||
return { type: 'vacation', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
const normalizeDefenceTrain = (value: number): number => {
|
||||
if (value <= 40) {
|
||||
return 40;
|
||||
}
|
||||
if (value <= 90) {
|
||||
return Math.round(value / 10) * 10;
|
||||
}
|
||||
return 999;
|
||||
};
|
||||
|
||||
async function handleSetMySetting(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
|
||||
@@ -826,11 +852,48 @@ async function handleSetMySetting(
|
||||
reason: '장수 정보를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const settings = command.settings;
|
||||
const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80);
|
||||
const nextDefenceTrain =
|
||||
settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train);
|
||||
const nextMeta = { ...general.meta };
|
||||
|
||||
if (settings.tnmt !== undefined) {
|
||||
nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt;
|
||||
}
|
||||
if (settings.use_treatment !== undefined) {
|
||||
nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment));
|
||||
}
|
||||
if (settings.use_auto_nation_turn !== undefined) {
|
||||
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn;
|
||||
}
|
||||
|
||||
let nextTrain = general.train;
|
||||
let nextAtmos = general.atmos;
|
||||
if (nextDefenceTrain !== previousDefenceTrain) {
|
||||
nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1;
|
||||
nextMeta.defence_train = nextDefenceTrain;
|
||||
if (nextDefenceTrain === 999) {
|
||||
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
||||
const ignoresPenalty =
|
||||
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
|
||||
scenarioEffect === 'event_StrongAttacker' ||
|
||||
scenarioEffect === 'event_MoreEffect';
|
||||
const constValues = asRecord(world.getScenarioConfig().const);
|
||||
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
|
||||
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
|
||||
const trainDelta = ignoresPenalty ? 0 : -3;
|
||||
const atmosDelta = ignoresPenalty ? 0 : -6;
|
||||
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
|
||||
nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta));
|
||||
}
|
||||
}
|
||||
|
||||
world.updateGeneral(command.generalId, {
|
||||
meta: {
|
||||
...general.meta,
|
||||
...command.settings,
|
||||
},
|
||||
meta: nextMeta,
|
||||
train: nextTrain,
|
||||
atmos: nextAtmos,
|
||||
});
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
@@ -844,10 +907,8 @@ async function handleDropItem(
|
||||
if (!general) {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const slot = (['horse', 'weapon', 'book', 'item'] as const).find(
|
||||
(candidate) => general.role.items[candidate] === command.itemType
|
||||
);
|
||||
if (!slot) {
|
||||
const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType);
|
||||
if (!slot || !general.role.items[slot]) {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
|
||||
}
|
||||
const nextGeneral = {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id: 7,
|
||||
userId: 'user-7',
|
||||
name: '테스트장수',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
turnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {
|
||||
killturn: 12,
|
||||
myset: 3,
|
||||
defence_train: 80,
|
||||
tnmt: 0,
|
||||
use_treatment: 10,
|
||||
use_auto_nation_turn: 1,
|
||||
},
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 100,
|
||||
crewTypeId: 0,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildWorld = (
|
||||
general = buildGeneral(),
|
||||
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
meta: {
|
||||
killturn: 24,
|
||||
autorun_user: options.autorunLimit ? { limit_minutes: 60 } : {},
|
||||
},
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { maxTrainByWar: 100, maxAtmosByWar: 100 },
|
||||
environment: {
|
||||
mapName: 'test',
|
||||
unitSet: 'test',
|
||||
...(options.scenarioEffect !== undefined ? { scenarioEffect: options.scenarioEffect } : {}),
|
||||
},
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'test',
|
||||
startYear: 180,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
return { world, handler: createTurnDaemonCommandHandler({ world }) };
|
||||
};
|
||||
|
||||
describe('my information world commands', () => {
|
||||
it('normalizes legacy settings and charges myset only when defence mode changes', async () => {
|
||||
const fixture = buildWorld();
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
generalId: 7,
|
||||
settings: {
|
||||
tnmt: 9,
|
||||
defence_train: 94,
|
||||
use_treatment: 200,
|
||||
use_auto_nation_turn: 0,
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject({
|
||||
train: 87,
|
||||
atmos: 84,
|
||||
meta: {
|
||||
tnmt: 1,
|
||||
defence_train: 999,
|
||||
use_treatment: 100,
|
||||
use_auto_nation_turn: 0,
|
||||
myset: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
generalId: 7,
|
||||
settings: { tnmt: 0, defence_train: 999, use_treatment: 1 },
|
||||
});
|
||||
expect(fixture.world.getGeneralById(7)?.meta).toMatchObject({
|
||||
tnmt: 0,
|
||||
use_treatment: 10,
|
||||
myset: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the event scenarios that waive the no-defence penalty', async () => {
|
||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
generalId: 7,
|
||||
settings: { defence_train: 999 },
|
||||
});
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
|
||||
});
|
||||
|
||||
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
|
||||
const allowed = buildWorld();
|
||||
await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true });
|
||||
expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72);
|
||||
|
||||
const blocked = buildWorld(buildGeneral(), { autorunLimit: true });
|
||||
await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
|
||||
});
|
||||
expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12);
|
||||
});
|
||||
|
||||
it('drops only the authenticated command target slot and rejects an empty slot', async () => {
|
||||
const fixture = buildWorld();
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' })
|
||||
).resolves.toMatchObject({ ok: false });
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' })
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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('권한이 부족합니다.');
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
||||
'troop.spec.ts',
|
||||
'board.spec.ts',
|
||||
'inGameInfo.spec.ts',
|
||||
'inGameMenus.spec.ts',
|
||||
'nationOffices.spec.ts',
|
||||
'nationGeneralSecret.spec.ts',
|
||||
'npcPolicy.spec.ts',
|
||||
|
||||
@@ -21,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
|
||||
import TournamentView from '../views/TournamentView.vue';
|
||||
import BettingView from '../views/BettingView.vue';
|
||||
import MyPageView from '../views/MyPageView.vue';
|
||||
import MySettingsView from '../views/MySettingsView.vue';
|
||||
import BoardView from '../views/BoardView.vue';
|
||||
import DiplomacyView from '../views/DiplomacyView.vue';
|
||||
import BestGeneralView from '../views/BestGeneralView.vue';
|
||||
@@ -283,8 +282,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/my-settings',
|
||||
name: 'my-settings',
|
||||
component: MySettingsView,
|
||||
redirect: '/my-page',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ watch(
|
||||
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
||||
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
||||
<RouterLink class="ghost" to="/my-page">내 정보&설정</RouterLink>
|
||||
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
|
||||
>토너먼트</RouterLink
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user