Merge remote-tracking branch 'origin/main' into feature/recruitment-command-parity-20260813

# Conflicts:
#	app/game-api/src/router/turns/index.ts
#	app/game-api/src/turns/commandInput.ts
#	app/game-frontend/e2e/commandArguments.spec.ts
#	app/game-frontend/src/components/command/ReservedCommandEditor.vue
#	app/game-frontend/src/components/command/types.ts
#	app/game-frontend/src/views/ChiefCenterView.vue
This commit is contained in:
2026-08-13 16:28:35 +00:00
83 changed files with 4463 additions and 1050 deletions
+30 -2
View File
@@ -137,7 +137,24 @@ export const tournamentRouter = router({
store.getMatches(), store.getMatches(),
store.getBettingEntries(), store.getBettingEntries(),
]); ]);
return { state, participants, matches, betCount: bets.length }; const participantIds = [...new Set(participants.map((participant) => participant.id))];
const iconRows =
participantIds.length === 0
? []
: await ctx.db.general.findMany({
where: { id: { in: participantIds } },
select: { id: true, picture: true, imageServer: true },
});
const iconsByGeneralId = new Map(iconRows.map((general) => [general.id, general]));
const publicParticipants = participants.map((participant) => {
const icon = iconsByGeneralId.get(participant.id);
return {
...participant,
picture: icon?.picture ?? null,
imageServer: icon?.imageServer ?? 0,
};
});
return { state, participants: publicParticipants, matches, betCount: bets.length };
}), }),
getRankings: authedProcedure.query(async ({ ctx }) => { getRankings: authedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx); await getMyGeneral(ctx);
@@ -177,7 +194,16 @@ export const tournamentRouter = router({
} }
const generals = await ctx.db.general.findMany({ const generals = await ctx.db.general.findMany({
where: { id: { in: [...rankMap.keys()] } }, where: { id: { in: [...rankMap.keys()] } },
select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true }, select: {
id: true,
name: true,
npcState: true,
picture: true,
imageServer: true,
leadership: true,
strength: true,
intel: true,
},
}); });
return tournamentRankTypes.map((prefix) => { return tournamentRankTypes.map((prefix) => {
@@ -201,6 +227,8 @@ export const tournamentRouter = router({
generalId: general.id, generalId: general.id,
name: general.name, name: general.name,
npcState: general.npcState, npcState: general.npcState,
picture: general.picture,
imageServer: general.imageServer,
stat, stat,
games: win + draw + lose, games: win + draw + lose,
win, win,
+49 -5
View File
@@ -102,6 +102,24 @@ const resolveMapName = (worldState: WorldStateRow, fallback: string): string =>
return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback; return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback;
}; };
const plainLegacyInfo = (value: string): string =>
value
.replace(/<br\s*\/?>/giu, ' · ')
.replace(/<[^>]+>/gu, '')
.replace(/\s+/gu, ' ')
.trim();
const readGeneralMetaNumber = (meta: unknown, key: string): number | null => {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
const value = (meta as Record<string, unknown>)[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
};
const assertReservedTurnPermission = async ( const assertReservedTurnPermission = async (
worldState: WorldStateRow, worldState: WorldStateRow,
general: GeneralRow, general: GeneralRow,
@@ -183,7 +201,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
}; };
for (const item of moduleBundle.itemModules) { for (const item of moduleBundle.itemModules) {
if (item.buyable) { if (item.buyable) {
items[item.slot].push({ value: item.key, label: item.name }); const cost = item.cost ?? 0;
const currentSecurity = city?.security ?? 0;
const availability =
currentSecurity < item.reqSecu
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
: general.gold < cost
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
: '현재 구입 가능';
items[item.slot].push({
value: item.key,
label: item.name,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
} }
} }
const inputOptions: TurnCommandInputOptions = { const inputOptions: TurnCommandInputOptions = {
@@ -205,11 +235,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
crewTypes: (environment.unitSet.crewTypes ?? []) crewTypes: (environment.unitSet.crewTypes ?? [])
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible')) .filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
.map((entry) => ({ value: entry.id, label: entry.name })), .map((entry) => ({ value: entry.id, label: entry.name })),
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({ armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
value: Number(value), const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
label, return {
value: Number(value),
label,
...(dexterity === null ? {} : { description: `현재 숙련 ${dexterity.toLocaleString()}` }),
};
}),
nationTypes: traits.nationTypes.map((entry) => ({
value: entry.key,
label: entry.name,
description: plainLegacyInfo(entry.info),
})), })),
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({ colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
value: index, value: index,
label: `색상 ${index + 1}`, label: `색상 ${index + 1}`,
@@ -226,6 +264,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
unitSet: environment.unitSet, unitSet: environment.unitSet,
generalActionModules: moduleBundle.general, generalActionModules: moduleBundle.general,
}), }),
context: {
actorGold: general.gold,
actorRice: general.rice,
...(city ? { citySecurity: city.security } : {}),
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
},
}; };
return buildTurnCommandTable({ return buildTurnCommandTable({
+42 -12
View File
@@ -15,6 +15,7 @@ export interface TurnCommandOption {
value: TurnCommandOptionValue; value: TurnCommandOptionValue;
label: string; label: string;
color?: string; color?: string;
description?: string;
} }
export interface TurnCommandRecruitmentCrewType { export interface TurnCommandRecruitmentCrewType {
@@ -50,14 +51,7 @@ export interface TurnCommandRecruitmentInfo {
} }
export type TurnCommandOptionSource = export type TurnCommandOptionSource =
| 'cities' 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
| 'nations'
| 'generals'
| 'crewTypes'
| 'armTypes'
| 'nationTypes'
| 'colors'
| 'items';
export interface TurnCommandInputField { export interface TurnCommandInputField {
key: string; key: string;
@@ -83,14 +77,50 @@ export interface TurnCommandInputOptions {
colors: TurnCommandOption[]; colors: TurnCommandOption[];
items: Record<string, TurnCommandOption[]>; items: Record<string, TurnCommandOption[]>;
recruitment: TurnCommandRecruitmentInfo | null; recruitment: TurnCommandRecruitmentInfo | null;
context?: {
actorGold: number;
actorRice: number;
citySecurity?: number;
nationGold?: number;
nationRice?: number;
nationLevel?: number;
};
} }
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다. // 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
export const TURN_COMMAND_NATION_COLORS = [ export const TURN_COMMAND_NATION_COLORS = [
'#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00', '#FF0000',
'#7CFC00', '#00FF00', '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED', '#800000',
'#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', '#00BFFF', '#0000FF', '#000080', '#483D8B', '#A0522D',
'#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', '#E0FFFF', '#FFFFFF', '#FF6347',
'#FFA500',
'#FFDAB9',
'#FFD700',
'#FFFF00',
'#7CFC00',
'#00FF00',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#20B2AA',
'#6495ED',
'#7FFFD4',
'#AFEEEE',
'#87CEEB',
'#00FFFF',
'#00BFFF',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#BA55D3',
'#800080',
'#FF00FF',
'#FFC0CB',
'#F5F5DC',
'#E0FFFF',
'#FFFFFF',
'#A9A9A9', '#A9A9A9',
] as const; ] as const;
@@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
id, id,
userId, userId,
name: `장수${id}`, name: `장수${id}`,
picture: `${id}.jpg`,
imageServer: id % 2,
leadership: 70 + id, leadership: 70 + id,
strength: 60 + id, strength: 60 + id,
intel: 50 + id, intel: 50 + id,
@@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => {
const sections = await ownerCaller.tournament.getRankings(); const sections = await ownerCaller.tournament.getRankings();
expect(sections).toHaveLength(4); expect(sections).toHaveLength(4);
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]); expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
expect(sections[0]?.entries[0]).toMatchObject({
picture: '2.jpg',
imageServer: 0,
});
const generalLessCaller = appRouter.createCaller( const generalLessCaller = appRouter.createCaller(
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows }) buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
@@ -303,6 +309,33 @@ describe('tournament router permissions and mutations', () => {
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' }); await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
}); });
it('joins current dedicated icon metadata to the public tournament snapshot', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const owner = buildGeneral(11, 'user-1');
const rival = buildGeneral(12, 'user-2');
await setTournamentFixture(redis, {
stage: 7,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
});
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' })
);
const snapshot = await caller.tournament.getSnapshot();
expect(snapshot.participants).toEqual([
expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }),
expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }),
]);
});
it('refunds gold when the tournament bet rank update fails', async () => { it('refunds gold when the tournament bet rank update fails', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
@@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => {
create: { create: {
profileName, profileName,
profile: 'runtime', profile: 'runtime',
instanceKey: 'consumer-integration',
currentScenario: 'consumer-integration',
scenario: 'consumer-integration', scenario: 'consumer-integration',
apiPort: 15998, apiPort: 15998,
status: 'RUNNING', status: 'RUNNING',
+21 -5
View File
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
'src', 'src',
'https://sam-image.hided.net/icons/22.jpg' 'https://sam-image.hided.net/icons/22.jpg'
); );
await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20');
await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25');
if (artifactRoot) { if (artifactRoot) {
await page.screenshot({ await page.screenshot({
path: resolve(artifactRoot, 'board-core-desktop.png'), path: resolve(artifactRoot, 'board-core-desktop.png'),
@@ -392,7 +394,9 @@ test('uses the ref 500px responsive form widths', async ({ page }) => {
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible(); await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
}); });
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => { test('retains article and comment input after a failed mutation, then reloads after success', async ({
page,
}, testInfo) => {
const state: BoardFixture = { const state: BoardFixture = {
permission: 2, permission: 2,
canMeeting: true, canMeeting: true,
@@ -408,7 +412,9 @@ test('retains article and comment input after a failed mutation, then reloads af
await page.locator('#board-title').fill('새 제목'); await page.locator('#board-title').fill('새 제목');
await page.locator('#board-content').fill('새 내용'); await page.locator('#board-content').fill('새 내용');
await page.locator('#submitArticle').click(); await page.locator('#submitArticle').click();
const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' }); const articleToast = page
.getByTestId('game-toast')
.filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error'); await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error');
await expect(articleToast).toHaveAttribute('role', 'alert'); await expect(articleToast).toHaveAttribute('role', 'alert');
await expect(page.locator('#board-title')).toHaveValue('새 제목'); await expect(page.locator('#board-title')).toHaveValue('새 제목');
@@ -416,7 +422,13 @@ test('retains article and comment input after a failed mutation, then reloads af
const desktopToastGeometry = await articleToast.evaluate((element) => { const desktopToastGeometry = await articleToast.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth }; return {
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
viewportWidth: window.innerWidth,
};
}); });
expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0); expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0);
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth); expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
@@ -435,10 +447,14 @@ test('retains article and comment input after a failed mutation, then reloads af
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth); const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
await commentInput.press('Enter'); await commentInput.press('Enter');
const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' }); const commentToast = page
.getByTestId('game-toast')
.filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
await expect(commentToast).toBeVisible(); await expect(commentToast).toBeVisible();
await expect await expect
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)) .poll(async () =>
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
)
.toBeGreaterThanOrEqual(0); .toBeGreaterThanOrEqual(0);
const mobileToastGeometry = await commentToast.evaluate((element) => { const mobileToastGeometry = await commentToast.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
+156 -9
View File
@@ -15,11 +15,11 @@ const operations = (route: Route) =>
const inputOptions = { const inputOptions = {
cities: [ cities: [
{ value: 1, label: '업 (아국)' }, { value: 1, label: '업 (아국)' },
{ value: 2, label: '허창 (적국)' }, { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
], ],
nations: [ nations: [
{ value: 1, label: '아국', color: '#008000' }, { value: 1, label: '아국', color: '#008000' },
{ value: 2, label: '적국', color: '#800000' }, { value: 2, label: '적국', color: '#800000', description: '수도 허창' },
], ],
generals: [ generals: [
{ value: 1, label: '장수 (아국 · 업)' }, { value: 1, label: '장수 (아국 · 업)' },
@@ -75,6 +75,14 @@ const inputOptions = {
}, },
], ],
}, },
context: {
actorGold: 1000,
actorRice: 1000,
citySecurity: 500,
nationGold: 5000,
nationRice: 6000,
nationLevel: 1,
},
}; };
const commandTable = { const commandTable = {
general: [ general: [
@@ -152,6 +160,27 @@ const commandTable = {
}, },
], ],
}, },
{
category: '외교',
values: [
{
key: 'che_선전포고',
name: '선전포고',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{
key: 'destNationId',
label: '대상 국가',
kind: 'select',
required: true,
optionSource: 'nations',
},
],
},
],
},
], ],
inputOptions, inputOptions,
}; };
@@ -177,8 +206,46 @@ const generalContext = {
dedication: 0, dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
}, },
city: { id: 1, name: '업', level: 8, region: 1, population: 1000, populationMax: 2000 }, city: {
nation: { id: 1, name: '아국', color: '#008000', level: 1 }, id: 1,
name: '업',
level: 8,
levelName: '특',
region: 1,
regionName: '하북',
nationId: 1,
nationName: '아국',
population: 1000,
populationMax: 2000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
trust: 70,
trade: 100,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
supplyState: 1,
frontState: 0,
},
nation: {
id: 1,
name: '아국',
color: '#008000',
level: 1,
levelName: '호족',
gold: 5000,
rice: 6000,
tech: 100,
typeCode: 'che_중립',
typeName: '중립',
capitalCityId: 1,
capitalCityName: '업',
},
settings: {}, settings: {},
penalties: {}, penalties: {},
}; };
@@ -247,8 +314,11 @@ const install = async (page: Page, rejectGeneral = false) => {
if (name === 'world.getMapLayout') if (name === 'world.getMapLayout')
return response({ return response({
mapName: 'che', mapName: 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [] }], cityList: [
regionMap: { 1: '하북' }, { id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [2] },
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
],
regionMap: { 1: '하북', 2: '예주' },
levelMap: { 8: '특' }, levelMap: { 8: '특' },
}); });
if (name === 'auth.status') return response({ ok: true }); if (name === 'auth.status') return response({ ok: true });
@@ -271,8 +341,14 @@ const install = async (page: Page, rejectGeneral = false) => {
startYear: 180, startYear: 180,
year: 200, year: 200,
month: 1, month: 1,
cityList: [[1, 8, 0, 1, 1, 1]], cityList: [
nationList: [[1, '아국', '#008000', 1]], [1, 8, 0, 1, 1, 1],
[2, 7, 40, 2, 2, 1],
],
nationList: [
[1, '아국', '#008000', 1],
[2, '적국', '#800000', 2],
],
spyList: {}, spyList: {},
shownByGeneralList: [], shownByGeneralList: [],
myCity: 1, myCity: 1,
@@ -341,13 +417,35 @@ const install = async (page: Page, rejectGeneral = false) => {
test('enters general and nation command arguments and sends exact values', async ({ page }) => { test('enters general and nation command arguments and sends exact values', async ({ page }) => {
const requests = await install(page); const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
const form = page.getByTestId('command-argument-form'); const form = page.getByTestId('command-argument-form');
await expect(form).toBeVisible(); await expect(form).toBeVisible();
await form.locator('select').selectOption('2'); await expect(form.getByTestId('command-argument-map')).toBeVisible();
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).hover();
expect(
await form
.getByTestId('command-argument-map')
.locator('.map-city')
.nth(1)
.evaluate((element) => getComputedStyle(element).cursor)
).toBe('pointer');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).focus();
await expect(form.getByTestId('command-argument-map').locator('.map-city').nth(1)).toBeFocused();
await expect(page).toHaveURL(/\/$/);
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
const area = element.querySelector<HTMLElement>('.map-area')!;
const rect = area.getBoundingClientRect();
return { width: rect.width, height: rect.height };
});
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계'); await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계');
@@ -379,6 +477,9 @@ test('enters general and nation command arguments and sends exact values', async
expect(JSON.stringify(requests)).toContain('"amount":300'); expect(JSON.stringify(requests)).toContain('"amount":300');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
expect(mapGeometry.width).toBeGreaterThan(650);
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
expect(geometry.width).toBeGreaterThan(200); expect(geometry.width).toBeGreaterThan(200);
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34); expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
expect(geometry.borderStyle).toBe('solid'); expect(geometry.borderStyle).toBe('solid');
@@ -493,6 +594,52 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금'); await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
}); });
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
await picker.getByRole('button', { name: /선전포고/ }).click();
const form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
await expect(page).toHaveURL(/\/che\/chief-center$/);
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
});
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /화계/ }).click();
const geometry = await picker.evaluate((element) => {
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
const pickerRect = element.getBoundingClientRect();
const mapRect = map.getBoundingClientRect();
return {
pickerX: pickerRect.x,
pickerRight: pickerRect.right,
pickerWidth: pickerRect.width,
pickerScrollWidth: element.scrollWidth,
mapWidth: mapRect.width,
mapHeight: mapRect.height,
};
});
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
expect(geometry.pickerRight).toBeLessThanOrEqual(500);
expect(geometry.pickerWidth).toBeGreaterThanOrEqual(488);
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
expect(geometry.mapWidth).toBeGreaterThan(470);
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
});
test('keeps the entered command visible and reports a server validation error', async ({ page }) => { test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
await install(page, true); await install(page, true);
await page.goto('/'); await page.goto('/');
+63 -14
View File
@@ -1,6 +1,6 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path'; import { basename, resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } }); const response = (data: unknown) => ({ result: { data } });
@@ -20,6 +20,23 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
]); ]);
}; };
const readGeneralPanelImages = async (panel: Locator) =>
panel.evaluate((element) =>
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
const rect = image.getBoundingClientRect();
const style = getComputedStyle(image);
return {
label: image.getAttribute('aria-label'),
width: rect.width,
height: rect.height,
backgroundImage: style.backgroundImage,
backgroundSize: style.backgroundSize,
pointerEvents: style.pointerEvents,
userSelect: style.userSelect,
};
})
);
type FixtureState = { type FixtureState = {
permission: 'head' | 'member'; permission: 'head' | 'member';
myset: number; myset: number;
@@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
const generalCard = page.locator('.general-card'); const generalCard = page.locator('.general-card');
await expect(generalCard).toContainText('성격안전'); await expect(generalCard).toContainText('성격안전');
await expect(generalCard).toContainText('전투특기신산'); await expect(generalCard).toContainText('특기상재 / 신산');
await expect(generalCard).toContainText('내정특기상재');
await expect(generalCard).not.toContainText('che_'); await expect(generalCard).not.toContainText('che_');
await expect(generalCard).toHaveAttribute('data-general-basic-card', '');
const mainImages = await readGeneralPanelImages(generalCard);
expect(mainImages).toHaveLength(2);
expect(mainImages[0]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
expect(mainImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(mainImages[1]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
expect(mainImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
const geometry = await nationCard.evaluate((element) => { const geometry = await nationCard.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -587,9 +610,9 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
await expect(nationCard).toContainText('국가 등급주자사'); await expect(nationCard).toContainText('국가 등급주자사');
const generalCard = page.locator('.general-card'); const generalCard = page.locator('.general-card');
await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부'); await expect(generalCard.locator('.general-title')).toContainText('검증장수 간의대부 | 건강 】');
await expect(generalCard).toContainText('병종보병'); await expect(generalCard).toContainText('병종보병');
await expect(generalCard).toContainText('계급29품관'); await expect(generalCard).toContainText('계급 29품관');
const cityCard = page.locator('.city-card'); const cityCard = page.locator('.city-card');
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업'); await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
@@ -646,11 +669,19 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
expect(mobileWidth).toBe(1016); expect(mobileWidth).toBe(1016);
}); });
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-identity layout', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state); await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 }); await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page'); await page.goto('my-page');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
{ width: 64, height: 64 },
{ width: 64, height: 64 },
]);
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관'); await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
await expect(page.locator('.item-group')).toContainText('명마'); await expect(page.locator('.item-group')).toContainText('명마');
@@ -696,7 +727,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
}; };
}); });
expect(desktop.width).toBe(1000); expect(desktop.width).toBe(1000);
expect(desktop.minWidth).toBe('500px'); expect(desktop.minWidth).toBe('0px');
expect(desktop.fontSize).toBe('14px'); expect(desktop.fontSize).toBe('14px');
expect(desktop.columns.split(' ')).toHaveLength(2); expect(desktop.columns.split(' ')).toHaveLength(2);
expect(desktop.titleHeight).toBeCloseTo(54, 0); expect(desktop.titleHeight).toBeCloseTo(54, 0);
@@ -766,26 +797,39 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId'); expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
} }
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 390, height: 900 });
await page.reload(); await page.reload();
const mobile = await page.locator('#container').evaluate((element) => { const mobile = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect(); const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
const icon = element.querySelector<HTMLElement>('[data-general-basic-card] .general-icon')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('[data-general-basic-card] .general-title')!.getBoundingClientRect();
return { return {
width: rect.width, width: rect.width,
scrollWidth: document.documentElement.scrollWidth, scrollWidth: document.documentElement.scrollWidth,
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
settingsOffset: settings.x - rect.x, settingsOffset: settings.x - rect.x,
settingsWidth: settings.width, settingsWidth: settings.width,
identity: {
iconRight: icon.right,
nameLeft: name.left,
iconTop: icon.top,
iconBottom: icon.bottom,
nameTop: name.top,
nameBottom: name.bottom,
},
}; };
}); });
expect(mobile).toMatchObject({ expect(mobile).toMatchObject({
width: 500, width: 390,
scrollWidth: 500, scrollWidth: 390,
columns: '500px', columns: '390px',
settingsOffset: 0, settingsOffset: 0,
settingsWidth: 500, settingsWidth: 390,
}); });
expect(mobile.identity.nameLeft).toBeGreaterThanOrEqual(mobile.identity.iconRight - 1);
expect(mobile.identity.nameTop).toBeLessThan(mobile.identity.iconBottom);
expect(mobile.identity.nameBottom).toBeGreaterThan(mobile.identity.iconTop);
await persistParityArtifact(page, 'core-my-page-mobile', mobile); await persistParityArtifact(page, 'core-my-page-mobile', mobile);
}); });
@@ -1109,10 +1153,15 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
await page.getByRole('button', { name: '다음 ▶' }).click(); await page.getByRole('button', { name: '다음 ▶' }).click();
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)'); await expect(page.locator('.battle-general-name')).toContainText('검증장수 간의대부 | 건강 】');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.battle-general-extra')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_'); await expect(page.locator('.battle-general-card')).not.toContainText('che_');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2);
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
expect( expect(
+240 -3
View File
@@ -34,6 +34,8 @@ type NavigationFixture = {
largeCommandTable?: boolean; largeCommandTable?: boolean;
currentYear?: number; currentYear?: number;
currentMonth?: number; currentMonth?: number;
scenarioTitle?: string;
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
globalRecords?: Array<{ id: number; text: string }>; globalRecords?: Array<{ id: number; text: string }>;
generalRecords?: Array<{ id: number; text: string }>; generalRecords?: Array<{ id: number; text: string }>;
worldHistory?: Array<{ id: number; text: string }>; worldHistory?: Array<{ id: number; text: string }>;
@@ -309,6 +311,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
year: state.currentYear ?? 185, year: state.currentYear ?? 185,
month: state.currentMonth ?? 1, month: state.currentMonth ?? 1,
turnTerm: 10, turnTerm: 10,
scenarioTitle: state.scenarioTitle ?? '',
}); });
} }
if (operation === 'dashboard.getContextBundleDelta') { if (operation === 'dashboard.getContextBundleDelta') {
@@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
onlineGenerals: '메뉴검증장수', onlineGenerals: '메뉴검증장수',
nationNotice: '<p>국가 방침</p>', nationNotice: '<p>국가 방침</p>',
lastExecuted: null, lastExecuted: null,
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false }, latestVote:
state.latestVote === undefined
? { id: 9, title: '메뉴 설문', hasVoted: false }
: state.latestVote,
}); });
} }
if (operation === 'board.getAccess') { if (operation === 'board.getAccess') {
@@ -508,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
const waitForMain = async (page: Page) => { const waitForMain = async (page: Page) => {
await page.goto('./'); await page.goto('./');
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await expect(page.locator('.game-shell__title')).toBeVisible();
await expect(page.locator('.main-global-menu').first()).toBeVisible(); await expect(page.locator('.main-global-menu').first()).toBeVisible();
await expect(page.locator('.main-nation-menu')).toBeVisible(); await expect(page.locator('.main-nation-menu')).toBeVisible();
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3); await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
@@ -561,8 +567,24 @@ const persistArtifact = async (page: Page, name: string) => {
globalPopup: describe('#mobile-global-menu'), globalPopup: describe('#mobile-global-menu'),
nationPopup: describe('#mobile-nation-menu'), nationPopup: describe('#mobile-nation-menu'),
quickPopup: describe('#mobile-quick-menu'), quickPopup: describe('#mobile-quick-menu'),
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
(element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
borderTop: style.borderTop,
margin: style.margin,
};
}
),
}; };
}); });
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
if (await commandMenu.isVisible()) {
await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) });
}
await Promise.all([ await Promise.all([
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }), page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
@@ -576,6 +598,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
nationLevel: 3, nationLevel: 3,
stage: 1, stage: 1,
npcMode: 1, npcMode: 1,
scenarioTitle: '메인 화면 검증 시나리오',
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
}; };
@@ -589,6 +612,45 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.main-mobile-bottom')).toBeHidden();
await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-desktop')).toBeVisible();
await expect(page.locator('.layout-mobile')).toHaveCount(0); await expect(page.locator('.layout-mobile')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
const title = element.querySelector<HTMLElement>('.game-shell__title');
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
const activity = element.querySelector<HTMLElement>('.activity-status');
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!title || !subtitle || !activity || !tournament || !survey) {
throw new Error('main header status geometry is incomplete');
}
return {
title: title.getBoundingClientRect().toJSON(),
subtitle: subtitle.getBoundingClientRect().toJSON(),
activity: activity.getBoundingClientRect().toJSON(),
tournament: tournament.getBoundingClientRect().toJSON(),
survey: survey.getBoundingClientRect().toJSON(),
activityColumns: getComputedStyle(activity).gridTemplateColumns,
};
});
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0);
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2);
const tournamentStatusLink = page.locator('.tournament-status a');
const surveyStatusLink = page.locator('.vote-status a');
await tournamentStatusLink.hover();
await expect
.poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor))
.toBe('pointer');
await surveyStatusLink.focus();
await expect(surveyStatusLink).toBeFocused();
await expect
.poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine))
.toContain('underline');
const contentOrder = await page const contentOrder = await page
.locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]') .locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]')
.evaluateAll((elements) => .evaluateAll((elements) =>
@@ -642,7 +704,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(gameInfoButton).toBeFocused(); await expect(gameInfoButton).toBeFocused();
await gameInfoButton.click(); await gameInfoButton.click();
await page.getByRole('heading', { name: '전장 현황' }).click(); await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
}); });
@@ -882,6 +944,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await tenthTurnButton.click(); await tenthTurnButton.click();
const quickPicker = page.getByTestId('command-picker'); const quickPicker = page.getByTestId('command-picker');
await expect(quickPicker).toBeVisible(); await expect(quickPicker).toBeVisible();
await tenthTurnButton.click();
await expect(quickPicker).toBeHidden();
await tenthTurnButton.click();
await expect(quickPicker).toBeVisible();
const quickPickerAlignment = await quickPicker.evaluate((element) => { const quickPickerAlignment = await quickPicker.evaluate((element) => {
const row = element const row = element
.closest('.reserved-command-editor') .closest('.reserved-command-editor')
@@ -923,6 +989,30 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop); expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop); expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop); expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
const rangeMenu = page.locator('[data-main-target="commands"] .range-menu');
await rangeMenu.locator('summary').click();
const rangeDividers = rangeMenu.locator('.menu-divider');
await expect(rangeDividers).toHaveCount(1);
await expect(rangeDividers.first()).toBeVisible();
expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
'1px solid rgb(68, 68, 68)'
);
await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`);
await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(rangeMenu).not.toHaveAttribute('open', '');
const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu');
await selectedMenu.locator('summary').click();
const selectedMenuDividers = selectedMenu.locator('.menu-divider');
await expect(selectedMenuDividers).toHaveCount(3);
await expect(selectedMenuDividers.first()).toBeVisible();
expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
'1px solid rgb(68, 68, 68)'
);
await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`);
await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(selectedMenu).not.toHaveAttribute('open', '');
await page.locator('[data-main-target="commands"] .select-command').click(); await page.locator('[data-main-target="commands"] .select-command').click();
const picker = page.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
@@ -1111,6 +1201,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(mobileGeometry.controlBoxes).toHaveLength(3); expect(mobileGeometry.controlBoxes).toHaveLength(3);
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1); expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
await mobileTurnButton.click();
await expect(page.getByTestId('command-picker')).toBeVisible();
await mobileTurnButton.click();
await expect(page.getByTestId('command-picker')).toBeHidden();
await captureProgress('mobile-500'); await captureProgress('mobile-500');
}); });
@@ -1121,6 +1216,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
nationLevel: 3, nationLevel: 3,
stage: 6, stage: 6,
npcMode: 1, npcMode: 1,
scenarioTitle: '모바일 검증 시나리오',
latestVote: null,
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
}; };
@@ -1139,6 +1236,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await expect(page.locator('.main-mobile-bottom')).toBeVisible();
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!tournament || !survey) throw new Error('activity status is incomplete');
return {
width: element.getBoundingClientRect().width,
tournamentWidth: tournament.getBoundingClientRect().width,
surveyWidth: survey.getBoundingClientRect().width,
columns: getComputedStyle(element).gridTemplateColumns,
};
});
expect(activityGeometry).toMatchObject({
width: 500,
tournamentWidth: 250,
surveyWidth: 250,
columns: '250px 250px',
});
await expect await expect
.poll(() => .poll(() =>
page page
@@ -1175,6 +1293,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
}); });
test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => {
test.setTimeout(60_000);
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile viewport contract');
}
const deviceWidths = [360, 390, 480];
const measurements: Record<string, unknown> = {};
for (const deviceWidth of deviceWidths) {
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: deviceWidth, height: 844 },
screen: { width: deviceWidth, height: 844 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 6,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installFixture(mobilePage, state);
await waitForMain(mobilePage);
const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
screenWidth: screen.availWidth,
innerWidth: window.innerWidth,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
visualViewportScale: window.visualViewport?.scale ?? null,
documentScrollWidth: document.documentElement.scrollWidth,
canvas: {
left: rect.left,
right: rect.right,
width: rect.width,
},
};
});
expect(mainGeometry.viewportMeta).toBe('width=500');
expect(mainGeometry.screenWidth).toBe(deviceWidth);
expect(mainGeometry.layoutViewportWidth).toBe(500);
expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2);
expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2);
expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth);
expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 });
expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await mobilePage.screenshot({
path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`),
fullPage: true,
});
}
const routeGeometry: Record<string, unknown> = {};
if (deviceWidth === 390) {
for (const target of [
'chief-center',
'battle-center',
'inherit',
'nation-betting',
]) {
await mobilePage.goto(target);
await expect
.poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
.toBe('500px');
routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
left: rect.left,
right: rect.right,
width: rect.width,
};
});
const geometry = routeGeometry[target] as {
viewportMeta: string;
layoutViewportWidth: number;
visualViewportWidth: number;
left: number;
right: number;
width: number;
};
expect(geometry.viewportMeta).toBe('width=500');
expect(geometry.layoutViewportWidth).toBe(500);
expect(geometry.visualViewportWidth).toBeCloseTo(500, 2);
expect(geometry.left).toBeCloseTo(0, 2);
expect(geometry.right).toBeCloseTo(500, 2);
expect(geometry.width).toBeCloseTo(500, 2);
}
}
measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry };
await context.close();
}
if (artifactRoot) {
await writeFile(
resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'),
`${JSON.stringify(measurements, null, 2)}\n`
);
}
});
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 1, officerLevel: 1,
@@ -18,6 +18,8 @@ const general = {
stats: { leadership: 70, strength: 60, intelligence: 50 }, stats: { leadership: 70, strength: 60, intelligence: 50 },
experienceLevel: 9, experienceLevel: 9,
dedicationLevel: 1, dedicationLevel: 1,
dedicationText: '30품관',
bill: 600,
injury: 0, injury: 0,
gold: 1000, gold: 1000,
rice: 2000, rice: 2000,
@@ -28,6 +30,24 @@ const general = {
refreshScoreTotal: 10, refreshScoreTotal: 10,
permission: 'normal', permission: 'normal',
}; };
const otherGeneral = {
...general,
id: 2,
name: '다른장수',
npcState: 1,
stats: { leadership: 40, strength: 80, intelligence: 65 },
experienceLevel: 12,
dedicationLevel: 3,
dedicationText: '28품관',
bill: 1000,
gold: 3000,
rice: 500,
personality: { key: '용장', name: '용장', info: '공격적인 성격' },
specialDomestic: { key: '상재', name: '상재', info: '상업 특기' },
specialWar: { key: '돌격', name: '돌격', info: '전투 특기' },
belong: 4,
refreshScoreTotal: 20,
};
const install = async (page: Page, secretAllowed = true) => { const install = async (page: Page, secretAllowed = true) => {
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_general'); localStorage.setItem('sammo-game-token', 'ga_general');
@@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => {
return response({ return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 }, nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 0 }, viewer: { generalId: 1, permission: 0 },
generals: [general], generals: [general, otherGeneral],
}); });
if (operation === 'nation.getSecretGeneralList') { if (operation === 'nation.getSecretGeneralList') {
if (!secretAllowed) if (!secretAllowed)
@@ -108,19 +128,91 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals'); await page.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toContainText('테스트장수'); await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
await expect(page.locator('#nation-general-list')).toContainText('?');
const computed = await page.locator('.general-page').evaluate((element) => { const computed = await page.locator('.general-page').evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const style = getComputedStyle(element); const style = getComputedStyle(element);
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily }; return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
}); });
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' }); expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' });
expect(computed.fontFamily).toContain('Times New Roman'); expect(computed.fontFamily).toContain('Pretendard');
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe( expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
'separate' 'separate'
); );
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030); expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000);
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66); expect((await page.locator('#nation-general-list tbody tr').first().boundingBox())?.height).toBe(68);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.locator('#nation-general-list')).toContainText('?');
});
test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals');
const table = page.locator('#nation-general-list');
await page.screenshot({ path: testInfo.outputPath('core-initial.png'), fullPage: true });
const statGroupButton = page.getByRole('button', { name: '능력치 접기' });
await expect(statGroupButton).toHaveAttribute('aria-expanded', 'true');
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
await statGroupButton.hover();
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(48, 54, 56)');
await statGroupButton.focus();
await expect(statGroupButton).toBeFocused();
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid');
await statGroupButton.click();
await page.screenshot({ path: testInfo.outputPath('core-stat-collapsed.png'), fullPage: true });
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
await expect(table.locator('thead')).toContainText('통|무|지');
await expect(table.locator('tr[data-general-id="1"]')).toContainText('70|60|50');
await page.getByRole('button', { name: '능력치 펼치기' }).click();
await page.getByLabel('장수명 필터').fill('ㅌㅅㅌㅈㅅ');
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('장수명 필터').fill('');
await page.getByLabel('통솔 필터').fill('>= 60');
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('통솔 필터').fill('');
await page.getByRole('button', { name: '통솔 정렬' }).click();
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '1');
await page.getByRole('button', { name: '통솔 정렬' }).click();
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '2');
await page.getByRole('button', { name: '능력치 접기' }).click();
await page.getByRole('button', { name: '열 선택⌄' }).click();
await page.getByLabel('쌀', { exact: true }).uncheck();
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('prompt');
await dialog.accept('내 보기');
});
await page.getByRole('button', { name: /보관하기/ }).click();
await expect
.poll(() =>
page.evaluate(() => ({
settings: localStorage.getItem('GeneralListDisplaySetting'),
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
}))
)
.toMatchObject({ settings: expect.stringContaining('내 보기'), last: '[false,"내 보기"]' });
await page.reload();
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
await expect(page.getByRole('button', { name: '내 보기', exact: true })).toBeVisible();
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
await dialog.accept();
});
await page.getByRole('button', { name: '내 보기 설정 삭제' }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting')))
.not.toContain('내 보기');
}); });
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
+130 -6
View File
@@ -1,15 +1,22 @@
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises'; import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR;
const imageRoots = [ const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []), ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../image/game'),
resolve(repositoryRoot, '../../image/game'), resolve(repositoryRoot, '../../image/game'),
]; ];
const iconRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'icons')] : []),
resolve(repositoryRoot, '../image/icons'),
resolve(repositoryRoot, '../../image/icons'),
resolve(repositoryRoot, '../../sam_rebuild/image/icons'),
];
const names = [ const names = [
'관우', '관우',
'장료', '장료',
@@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({
strength: 80, strength: 80,
intel: 80, intel: 80,
level: 10, level: 10,
picture: 'default.jpg',
imageServer: 0,
groupId: 10 + (index % 8), groupId: 10 + (index % 8),
groupNo: Math.floor(index / 8), groupNo: Math.floor(index / 8),
win: 3 - (index % 2), win: 3 - (index % 2),
@@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
throw new Error(`Reference image not found: ${filename}`); throw new Error(`Reference image not found: ${filename}`);
}; };
const readReferenceIcon = async (filename: string): Promise<Buffer> => {
for (const iconRoot of iconRoots) {
try {
return await readFile(resolve(iconRoot, filename));
} catch {
// Worktrees can be nested at different depths.
}
}
throw new Error(`Reference icon not found: ${filename}`);
};
const persistScreenshot = async (page: Page, name: string, fallbackPath: string) => {
if (!responsiveArtifactDir) {
await page.screenshot({ path: fallbackPath, fullPage: true });
return;
}
await mkdir(responsiveArtifactDir, { recursive: true });
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
};
const installFixture = async (page: Page) => { const installFixture = async (page: Page) => {
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
@@ -98,6 +127,9 @@ const installFixture = async (page: Page) => {
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) }); await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) });
}); });
} }
await page.route('**/icons/default.jpg', async (route) => {
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceIcon('default.jpg') });
});
await page.route(gameTrpcRoute, async (route) => { await page.route(gameTrpcRoute, async (route) => {
const results = operationNames(route).map((operation) => { const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
@@ -125,12 +157,43 @@ const installFixture = async (page: Page) => {
} }
if (operation === 'tournament.getBettingSummary') { if (operation === 'tournament.getBettingSummary') {
return response({ return response({
totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])), totals: Object.fromEntries(
participants.map((participant, index) => [participant.id, 100 + index * 10])
),
myTotals: {}, myTotals: {},
totalAmount: 2800, totalAmount: 2800,
myAmount: 0, myAmount: 0,
}); });
} }
if (operation === 'tournament.getRankings') {
return response(
[
['tt', '전 력 전', '종합'],
['tl', '통 솔 전', '통솔'],
['ts', '일 기 토', '무력'],
['ti', '설 전', '지력'],
].map(([prefix, title, statLabel]) => ({
prefix,
title,
statLabel,
entries: participants.slice(0, 6).map((participant, index) => ({
rank: index + 1,
generalId: participant.id,
name: participant.name,
picture: participant.picture,
imageServer: participant.imageServer,
npcState: 0,
stat: 240 - index,
games: 10,
win: 7,
draw: 1,
lose: 2,
score: 22 - index,
prizes: 3,
})),
}))
);
}
return response(null); return response(null);
}); });
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
@@ -162,16 +225,20 @@ test('desktop bracket connects every real general slot to the next round', async
connectorCenter: firstConnector.x + firstConnector.width / 2, connectorCenter: firstConnector.x + firstConnector.width / 2,
championCenter: champion.x + champion.width / 2, championCenter: champion.x + champion.width / 2,
finalistCenters: finalists.map((rect) => rect.x + rect.width / 2), finalistCenters: finalists.map((rect) => rect.x + rect.width / 2),
connectorQuarters: [firstConnector.x + firstConnector.width / 4, firstConnector.x + (firstConnector.width * 3) / 4], connectorQuarters: [
firstConnector.x + firstConnector.width / 4,
firstConnector.x + (firstConnector.width * 3) / 4,
],
}; };
}); });
expect(geometry.canvasWidth).toBe(2000); expect(geometry.canvasWidth).toBeGreaterThanOrEqual(1000);
expect(geometry.canvasWidth).toBeLessThanOrEqual(1200);
expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1); expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
expect(geometry.finalistCenters).toHaveLength(2); expect(geometry.finalistCenters).toHaveLength(2);
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1); expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1); expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1);
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-desktop.webp'), fullPage: true }); await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
}); });
test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => { test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => {
@@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a
expect(bounds.width).toBe(390); expect(bounds.width).toBe(390);
expect(bounds.minX).toBeGreaterThanOrEqual(0); expect(bounds.minX).toBeGreaterThanOrEqual(0);
expect(bounds.maxX).toBeLessThanOrEqual(390); expect(bounds.maxX).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-mobile.webp'), fullPage: true }); const identity = await bracket
.locator('.mobile-bracket-name')
.first()
.evaluate((element) => {
const icon = element.querySelector('img')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
});
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
await page.getByRole('tab', { name: '二조' }).first().click();
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
});
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page);
await page.goto('betting');
await expect(page.locator('.candidate-card')).toHaveCount(16);
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
await page.getByRole('tab', { name: '통솔전' }).click();
await expect(page.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.ranking-table:visible thead')).toContainText('통 솔 전');
const identity = await page
.locator('.ranking-table:visible .general-identity')
.first()
.evaluate((element) => {
const icon = element.querySelector('img')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
});
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await persistScreenshot(page, 'tournament-ranking-mobile', testInfo.outputPath('tournament-ranking-mobile.webp'));
});
test('desktop betting presents icon-and-name cards and all four rankings without document overflow', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 });
await installFixture(page);
await page.goto('betting');
await expect(page.locator('.candidate-card')).toHaveCount(16);
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
const columns = await page
.locator('.candidate-grid')
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
expect(columns).toBe(4);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
}); });
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=500" />
<title>Sammo HiDCHe - Game</title> <title>Sammo HiDCHe - Game</title>
</head> </head>
<body class="bg-black text-white"> <body class="bg-black text-white">
+7
View File
@@ -39,6 +39,13 @@ body {
min-width: 500px; min-width: 500px;
} }
/* These redesigned identity/tournament screens own a true handheld layout. */
#app:has(.responsive-settings-page),
#app:has(#tournament-container),
#app:has(#tournament-betting-container) {
min-width: 320px;
}
body:has(.battle-page), body:has(.battle-page),
body:has(.chief-page), body:has(.chief-page),
body:has(.global-page), body:has(.global-page),
@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types'; import type {
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from '../command/types';
const props = defineProps<{ const props = defineProps<{
officerLevelText: string; officerLevelText: string;
@@ -13,6 +19,8 @@ const props = defineProps<{
generalId: number; generalId: number;
officerLevel: number; officerLevel: number;
mobile?: boolean; mobile?: boolean;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>(); }>();
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action }))); const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
@@ -37,6 +45,8 @@ const emit = defineEmits<{
:title="props.officerLevelText" :title="props.officerLevelText"
:name="props.name" :name="props.name"
:current-time="props.rows[0]?.time" :current-time="props.rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('reserve-bulk', $event)" @reserve-bulk="emit('reserve-bulk', $event)"
@shift="emit('shift', $event)" @shift="emit('shift', $event)"
@repeat="emit('repeat', $event)" @repeat="emit('repeat', $event)"
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, shallowRef, watch } from 'vue'; import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue'; import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue'; import CommandSelectForm from '../main/CommandSelectForm.vue';
import { commandArgumentPresentation } from './commandArgumentPresentation';
import DragSelect from './DragSelect.vue'; import DragSelect from './DragSelect.vue';
import RecruitmentCommandForm from './RecruitmentCommandForm.vue'; import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
import { import {
@@ -12,7 +13,14 @@ import {
normalizedSelection, normalizedSelection,
selectStep, selectStep,
} from './commandQueue'; } from './commandQueue';
import type { CommandAvailability, CommandPatternEntry, CommandTable, ReservedCommandRow } from './types'; import type {
CommandAvailability,
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from './types';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -27,8 +35,19 @@ const props = withDefaults(
title?: string; title?: string;
name?: string | null; name?: string | null;
currentTime?: string; currentTime?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>(), }>(),
{ maxPushTurn: 6, compact: false, mobile: false, title: '', name: null, currentTime: '--:--' } {
maxPushTurn: 6,
compact: false,
mobile: false,
title: '',
name: null,
currentTime: '--:--',
mapData: null,
mapLayout: null,
}
); );
const emit = defineEmits<{ const emit = defineEmits<{
@@ -153,6 +172,14 @@ const closePicker = () => {
quickTarget.value = null; quickTarget.value = null;
selectedCommand.value = null; selectedCommand.value = null;
}; };
const togglePicker = (turnIndex?: number) => {
const target = turnIndex ?? null;
if (pickerOpen.value && quickTarget.value === target) {
closePicker();
return;
}
openPicker(turnIndex);
};
const selectCommand = (commandKey: string) => { const selectCommand = (commandKey: string) => {
const command = props.commandTable?.[props.scope] const command = props.commandTable?.[props.scope]
.flatMap((group) => group.values) .flatMap((group) => group.values)
@@ -242,7 +269,15 @@ const clickOutsideMenu = (event: Event) => {
<template> <template>
<article <article
class="reserved-command-editor" class="reserved-command-editor"
:class="{ compact: props.compact, mobile: props.mobile, 'edit-mode': editMode, 'picker-open': pickerOpen }" :class="{
compact: props.compact,
mobile: props.mobile,
'edit-mode': editMode,
'picker-open': pickerOpen,
'argument-expanded': Boolean(
selectedCommand?.reqArg && commandArgumentPresentation(selectedCommand.key).lines.length
),
}"
:data-command-scope="props.scope" :data-command-scope="props.scope"
> >
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1"> <header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
@@ -309,6 +344,7 @@ const clickOutsideMenu = (event: Event) => {
> >
짝수턴 짝수턴
</button> </button>
<hr class="menu-divider" />
<template v-for="step in [3, 4, 5, 6, 7]" :key="step"> <template v-for="step in [3, 4, 5, 6, 7]" :key="step">
<small>{{ step }} 간격</small> <small>{{ step }} 간격</small>
<div class="step-buttons"> <div class="step-buttons">
@@ -433,6 +469,7 @@ const clickOutsideMenu = (event: Event) => {
> >
붙여넣기 붙여넣기
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
textCopy(); textCopy();
@@ -441,6 +478,7 @@ const clickOutsideMenu = (event: Event) => {
> >
텍스트 복사 텍스트 복사
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
saveTemplate(); saveTemplate();
@@ -457,6 +495,7 @@ const clickOutsideMenu = (event: Event) => {
> >
반복하기 반복하기
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
clearSelection(); clearSelection();
@@ -483,7 +522,7 @@ const clickOutsideMenu = (event: Event) => {
</button> </button>
</div> </div>
</details> </details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button> <button type="button" class="select-command" @click="togglePicker()">명령 선택 </button>
</div> </div>
<div class="queue-area"> <div class="queue-area">
@@ -546,7 +585,7 @@ const clickOutsideMenu = (event: Event) => {
:key="row.index" :key="row.index"
type="button" type="button"
:aria-label="`${row.index + 1} 명령 입력`" :aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)" @click="togglePicker(row.index)"
> >
</button> </button>
@@ -606,6 +645,8 @@ const clickOutsideMenu = (event: Event) => {
:command-key="selectedCommand.key" :command-key="selectedCommand.key"
:fields="selectedCommand.inputFields" :fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions" :options="props.commandTable.inputOptions"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@update:args="commandArgs = $event" @update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event" @update:valid="commandArgsValid = $event"
/> />
@@ -725,6 +766,14 @@ const clickOutsideMenu = (event: Event) => {
padding: 5px 8px; padding: 5px 8px;
color: #bbb; color: #bbb;
} }
.menu-divider {
width: 100%;
height: 0;
margin: 4px 0;
border: 0;
border-top: 1px solid #444;
opacity: 1;
}
.step-buttons, .step-buttons,
.template-row { .template-row {
display: flex; display: flex;
@@ -933,6 +982,11 @@ const clickOutsideMenu = (event: Event) => {
} }
@media (min-width: 1025px) { @media (min-width: 1025px) {
.argument-expanded:not(.compact) .command-picker {
right: 0;
left: auto;
width: 700px;
}
.compact:not(.mobile) .command-picker { .compact:not(.mobile) .command-picker {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
@@ -941,6 +995,13 @@ const clickOutsideMenu = (event: Event) => {
left: calc(50% - 476px); left: calc(50% - 476px);
width: 238px; width: 238px;
} }
.compact.argument-expanded:not(.mobile) .command-picker {
left: calc(50% - 350px);
width: 700px;
height: auto;
max-height: calc(100vh - 104px);
overflow: auto;
}
.compact:not(.mobile) .command-picker.recruitment-picker { .compact:not(.mobile) .command-picker.recruitment-picker {
top: 76px; top: 76px;
left: 50%; left: 50%;
@@ -984,12 +1045,25 @@ const clickOutsideMenu = (event: Event) => {
width: 370px; width: 370px;
height: 327px; height: 327px;
} }
.mobile.compact.argument-expanded .command-picker {
position: relative;
top: auto;
left: auto;
width: 100%;
height: auto;
max-height: none;
margin-top: -330px;
overflow: visible;
}
.mobile.compact .command-picker.recruitment-picker { .mobile.compact .command-picker.recruitment-picker {
position: fixed;
top: 76px; top: 76px;
left: 0; left: 0;
width: 500px; width: 500px;
height: auto; height: auto;
max-height: calc(100vh - 82px); max-height: calc(100vh - 82px);
margin-top: 0;
overflow: auto;
transform: none; transform: none;
} }
.mobile.compact .advanced-actions { .mobile.compact .advanced-actions {
@@ -0,0 +1,92 @@
export type CommandArgumentPresentation = {
lines: string[];
mapTarget?: 'city' | 'nation';
};
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
che_강행: cityTarget(['선택한 도시로 강행합니다.', '최대 3칸 안의 도시만 선택할 수 있습니다.']),
che_이동: cityTarget(['선택한 도시로 이동합니다.', '인접한 도시로만 이동할 수 있습니다.']),
che_출병: cityTarget([
'선택한 도시를 향해 침공합니다.',
'침공 경로에 적군 도시가 있으면 그 도시에서 전투를 벌입니다.',
]),
che_첩보: cityTarget(['선택한 도시에 첩보를 실행합니다.', '인접 도시에서는 더 많은 정보를 얻습니다.']),
che_화계: cityTarget(['선택한 도시에 화계를 실행합니다.']),
che_탈취: cityTarget(['선택한 도시에 탈취를 실행합니다.']),
che_파괴: cityTarget(['선택한 도시에 파괴를 실행합니다.']),
che_선동: cityTarget(['선택한 도시에 선동을 실행합니다.']),
che_수몰: cityTarget(['선택한 도시에 수몰을 발동합니다.', '전쟁 중인 상대국 도시만 대상이 됩니다.']),
che_백성동원: cityTarget(['선택한 도시에 백성을 동원해 성벽을 쌓습니다.', '아국 도시만 대상이 됩니다.']),
che_천도: cityTarget([
'선택한 도시로 수도를 옮깁니다.',
'현재 수도에서 연결된 도시만 가능하며 1 + 2 × 거리만큼의 턴이 필요합니다.',
]),
che_허보: cityTarget(['선택한 도시에 허보를 발동합니다.', '선포 또는 전쟁 중인 상대국 도시만 대상이 됩니다.']),
che_초토화: cityTarget([
'선택한 도시를 초토화해 공백지로 만듭니다.',
'인구와 내정 상태에 따라 국고를 확보하고, 수뇌 명성과 모든 장수의 배신 수치에 영향을 줍니다.',
]),
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
che_선전포고: nationTarget([
'선택한 국가에 선전포고합니다.',
'고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.',
]),
che_급습: nationTarget(['선택한 국가에 급습을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
che_불가침파기제의: nationTarget(['불가침 중인 국가에 조약 파기를 제의합니다.']),
che_이호경식: nationTarget(['선택한 국가에 이호경식을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
che_종전제의: nationTarget(['전쟁 중인 국가에 종전을 제의합니다.']),
che_불가침제의: nationTarget([
'선택한 국가에 불가침을 제의합니다.',
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
]),
che_피장파장: nationTarget([
'선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.',
'아국에도 지정 전략의 재사용 제한이 생깁니다.',
]),
che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']),
che_증여: { lines: ['자신의 금이나 쌀을 선택한 장수에게 증여합니다.'] },
che_헌납: { lines: ['자신의 금이나 쌀을 국가 재산으로 헌납합니다.'] },
che_군량매매: { lines: ['자신의 군량을 사거나 팝니다.'] },
che_몰수: { lines: ['선택한 장수의 금이나 쌀을 몰수해 국가 재산으로 귀속합니다.'] },
che_포상: { lines: ['국고에서 선택한 장수에게 금이나 쌀을 지급합니다.'] },
che_부대탈퇴지시: { lines: ['선택한 장수에게 부대 탈퇴를 지시합니다.', '현재 부대원인 장수만 대상이 됩니다.'] },
che_등용: { lines: ['재야 또는 타국 장수에게 등용 서신을 보냅니다.', '서신은 개인 메시지로 전달됩니다.'] },
che_선양: { lines: ['군주의 자리를 선택한 아국 장수에게 물려줍니다.'] },
che_임관: {
lines: [
'선택한 국가에 임관하고 군주의 위치로 이동합니다.',
'이미 임관하거나 등용되었던 국가는 선택할 수 없습니다.',
],
},
che_장수대상임관: {
lines: ['선택한 장수를 따라 그 장수의 국가에 임관하고 군주의 위치로 이동합니다.'],
},
che_숙련전환: {
lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'],
},
che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '가격과 요구 치안, 장비 효과를 확인한 뒤 선택하세요.'] },
che_건국: {
lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
},
che_무작위건국: {
lines: ['무작위 공백 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
},
cr_건국: { lines: ['현재 도시에서 규모 제한 없이 나라를 세웁니다.', '국가 성향별 장단점을 확인하세요.'] },
che_국기변경: { lines: ['국기의 색상을 변경합니다.', '이 명령은 한 번만 실행할 수 있습니다.'] },
che_국호변경: { lines: ['국가 이름을 변경합니다.', '황제가 된 뒤 한 번만 실행할 수 있습니다.'] },
che_등용수락: { lines: ['도착한 등용 제의에 응할 행동을 선택합니다.'] },
che_NPC능동: { lines: ['NPC 장수의 능동 행동 방식을 선택합니다.'] },
};
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
PRESENTATIONS[commandKey] ?? { lines: [] };
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
@@ -1,4 +1,36 @@
export type CommandOption = { value: string | number; label: string; color?: string }; export type CommandOption = {
value: string | number;
label: string;
color?: string;
description?: string;
};
export type CommandMapData = {
year: number;
month: number;
startYear: number;
techLevelLimit?: { maxLevel: number; initialLevel: number; increaseYears: number };
cityList: [number, number, number, number, number, number][];
nationList: [number, string, string, number][];
myCity?: number | null;
myNation?: number | null;
};
export type CommandMapLayout = {
mapName: string;
cityList: Array<{ id: number; name: string; level: number; region: number; x: number; y: number; path: number[] }>;
regionMap: Record<number, string>;
levelMap: Record<number, string>;
};
export type CommandInputContext = {
actorGold: number;
actorRice: number;
citySecurity?: number;
nationGold?: number;
nationRice?: number;
nationLevel?: number;
};
export type CommandInputField = { export type CommandInputField = {
key: string; key: string;
@@ -69,6 +101,7 @@ export type CommandTable = {
colors: CommandOption[]; colors: CommandOption[];
items: Record<string, CommandOption[]>; items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null; recruitment: RecruitmentInfo | null;
context?: CommandInputContext;
}; };
}; };
@@ -1,40 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, watch } from 'vue'; import { computed, reactive, watch } from 'vue';
import MapViewer from './MapViewer.vue';
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
import type {
CommandInputContext,
CommandInputField,
CommandMapData,
CommandMapLayout,
CommandOption,
CommandTable,
} from '../command/types';
type OptionValue = string | number; type CommandInputOptions = CommandTable['inputOptions'];
interface CommandOption {
value: OptionValue;
label: string;
color?: string;
}
interface CommandInputField {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
required: boolean;
min?: number;
max?: number;
step?: number;
constValue?: OptionValue;
options?: CommandOption[];
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
}
interface CommandInputOptions {
cities: CommandOption[];
nations: CommandOption[];
generals: CommandOption[];
crewTypes: CommandOption[];
armTypes: CommandOption[];
nationTypes: CommandOption[];
colors: CommandOption[];
items: Record<string, CommandOption[]>;
}
const props = defineProps<{ const props = defineProps<{
commandKey: string; commandKey: string;
fields: CommandInputField[]; fields: CommandInputField[];
options: CommandInputOptions; options: CommandInputOptions;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -43,6 +27,8 @@ const emit = defineEmits<{
}>(); }>();
const values = reactive<Record<string, unknown>>({}); const values = reactive<Record<string, unknown>>({});
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
const optionsFor = (field: CommandInputField): CommandOption[] => { const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.options) return field.options; if (field.options) return field.options;
@@ -58,7 +44,16 @@ const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'boolean') return true; if (field.kind === 'boolean') return true;
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0]; if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
if (field.kind === 'number') return field.min ?? 0; if (field.kind === 'number') return field.min ?? 0;
if (field.kind === 'select') return optionsFor(field)[0]?.value ?? ''; if (field.kind === 'select') {
const options = optionsFor(field);
const mapDefault =
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
? props.mapData?.myCity
: field.optionSource === 'nations' && field.key === 'destNationId'
? props.mapData?.myNation
: null;
return options.find((option) => option.value === mapDefault)?.value ?? options[0]?.value ?? '';
}
return ''; return '';
}; };
@@ -78,6 +73,129 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
} }
}; };
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
optionsFor(field).find((entry) => entry.value === values[field.key]);
const cityTargetField = computed(() =>
props.fields.find(
(field) =>
field.kind === 'select' &&
field.optionSource === 'cities' &&
(field.key === 'destCityId' || field.key === 'destCityID')
)
);
const nationTargetField = computed(() =>
props.fields.find(
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
)
);
const showMap = computed(
() =>
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value))
);
const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return null;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
const value = values[cityTargetField.value.key];
return typeof value === 'number' ? value : null;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null;
return props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ?? null;
}
return null;
});
const distanceFromMyCity = (destination: number): number | null => {
const start = props.mapData?.myCity;
if (!start || !props.mapLayout) return null;
if (start === destination) return 0;
const paths = new Map(props.mapLayout.cityList.map((city) => [city.id, city.path]));
const visited = new Set<number>([start]);
let frontier = [start];
for (let distance = 1; frontier.length; distance += 1) {
const next: number[] = [];
for (const cityId of frontier) {
for (const adjacentId of paths.get(cityId) ?? []) {
if (visited.has(adjacentId)) continue;
if (adjacentId === destination) return distance;
visited.add(adjacentId);
next.push(adjacentId);
}
}
frontier = next;
}
return null;
};
const mapTargetSummary = computed(() => {
if (!props.mapData || !props.mapLayout) return '';
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === dynamic?.[3]);
const distance = distanceFromMyCity(city.id);
return [
city.name,
nation?.[1] ?? '무주',
props.mapLayout.regionMap[dynamic?.[4] ?? city.region],
props.mapLayout.levelMap[dynamic?.[1] ?? city.level],
distance === null ? null : `현재 도시에서 ${distance}`,
]
.filter(Boolean)
.join(' · ');
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
if (!nation) return '';
const capital = props.mapLayout.cityList.find((entry) => entry.id === nation[3]);
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`;
}
return '';
});
const selectMapCity = (cityId: number) => {
if (!props.mapData) return;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
setSelectValue(cityTargetField.value, String(cityId));
return;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
}
};
const resourceSummary = computed(() => {
const context: CommandInputContext | undefined = props.options.context;
if (!context) return [];
const result: string[] = [];
const usesActorResources = new Set(['che_증여', 'che_헌납', 'che_군량매매', 'che_장비매매']);
const usesNationResources = new Set(['che_몰수', 'che_포상', 'che_물자원조']);
if (usesActorResources.has(props.commandKey)) {
result.push(
`현재 자금 ${context.actorGold.toLocaleString()}`,
`현재 군량 ${context.actorRice.toLocaleString()}`
);
}
if (props.commandKey === 'che_장비매매' && context.citySecurity !== undefined) {
result.push(`현재 도시 치안 ${context.citySecurity.toLocaleString()}`);
}
if (usesNationResources.has(props.commandKey)) {
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
if (context.nationRice !== undefined) result.push(`국가 군량 ${context.nationRice.toLocaleString()}`);
if (context.nationLevel !== undefined) result.push(`국가 작위 ${context.nationLevel}`);
}
return result;
});
const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => { const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => {
const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0]; const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
tuple[index] = Number(rawValue); tuple[index] = Number(rawValue);
@@ -89,17 +207,32 @@ const isValid = computed(() =>
const value = values[field.key]; const value = values[field.key];
if (field.kind === 'text') { if (field.kind === 'text') {
const length = typeof value === 'string' ? value.trim().length : 0; const length = typeof value === 'string' ? value.trim().length : 0;
return (!field.required || length > 0) && (field.min === undefined || length >= field.min) && return (
(field.max === undefined || length <= field.max); (!field.required || length > 0) &&
(field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max)
);
} }
if (field.kind === 'number') { if (field.kind === 'number') {
return typeof value === 'number' && Number.isFinite(value) && return (
(field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max); typeof value === 'number' &&
Number.isFinite(value) &&
(field.min === undefined || value >= field.min) &&
(field.max === undefined || value <= field.max)
);
} }
if (field.kind === 'numberTuple') { if (field.kind === 'numberTuple') {
return Array.isArray(value) && value.length === 2 && return (
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry) && Array.isArray(value) &&
(field.min === undefined || entry >= field.min) && (field.max === undefined || entry <= field.max)); value.length === 2 &&
value.every(
(entry) =>
typeof entry === 'number' &&
Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) &&
(field.max === undefined || entry <= field.max)
)
);
} }
if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value); if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value);
return value !== undefined; return value !== undefined;
@@ -119,11 +252,28 @@ watch(
<template> <template>
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form"> <div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
<div <div v-if="showMap" class="command-map" data-testid="command-argument-map">
v-for="field in props.fields.filter((entry) => entry.kind !== 'hidden')" <MapViewer
:key="field.key" :map-data="props.mapData ?? null"
class="argument-row" :map-layout="props.mapLayout ?? null"
> :loading="false"
:selected-city-id="mapSelectedCityId"
:detail-mode="false"
:fit-container="true"
@select-city="selectMapCity"
/>
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
{{ mapTargetSummary }}
</div>
</div>
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
</div>
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
</div>
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
<label :for="`command-arg-${field.key}`">{{ field.label }}</label> <label :for="`command-arg-${field.key}`">{{ field.label }}</label>
<input <input
v-if="field.kind === 'text'" v-if="field.kind === 'text'"
@@ -182,6 +332,21 @@ watch(
/> />
</label> </label>
</div> </div>
<div
v-if="
field.kind === 'select' &&
(selectedOptionFor(field)?.description || selectedOptionFor(field)?.color)
"
class="option-detail"
>
<span
v-if="selectedOptionFor(field)?.color"
class="option-color"
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
aria-hidden="true"
/>
<span>{{ selectedOptionFor(field)?.description }}</span>
</div>
</div> </div>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div> <div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div> </div>
@@ -193,6 +358,43 @@ watch(
font-size: 0.75rem; font-size: 0.75rem;
} }
.command-map {
width: 100%;
overflow: hidden;
background: #111;
}
.command-map small {
display: block;
padding: 5px 8px;
color: rgba(232, 221, 196, 0.72);
}
.map-target-summary {
padding: 0 8px 6px;
color: #f1d89a;
line-height: 1.35;
}
.command-guidance {
display: grid;
gap: 3px;
padding: 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.35);
background: #191919;
color: #eee;
line-height: 1.35;
}
.resource-summary {
display: flex;
flex-wrap: wrap;
gap: 5px 14px;
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.25);
color: #f1d89a;
}
.argument-row { .argument-row {
display: grid; display: grid;
grid-template-columns: minmax(76px, 0.36fr) 1fr; grid-template-columns: minmax(76px, 0.36fr) 1fr;
@@ -200,6 +402,23 @@ watch(
align-items: center; align-items: center;
} }
.option-detail {
grid-column: 2;
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px 6px 0;
color: rgba(232, 221, 196, 0.74);
line-height: 1.35;
}
.option-color {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid #ddd;
}
.argument-row:nth-child(odd) { .argument-row:nth-child(odd) {
background: rgba(255, 255, 255, 0.035); background: rgba(255, 255, 255, 0.035);
} }
@@ -2,7 +2,13 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types'; import type {
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from '../command/types';
const props = defineProps<{ const props = defineProps<{
commandTable: CommandTable | null; commandTable: CommandTable | null;
@@ -14,6 +20,8 @@ const props = defineProps<{
turnTermMinutes?: number; turnTermMinutes?: number;
autorunLimit?: number | null; autorunLimit?: number | null;
storageKey?: string; storageKey?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -63,6 +71,8 @@ const rows = computed<ReservedCommandRow[]>(() => {
:loading="props.loading" :loading="props.loading"
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`" :storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
:current-time="rows[0]?.time" :current-time="rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('set-general-turns', $event)" @reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)" @shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)" @repeat="emit('repeat-general-turns', $event)"
@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue'; import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulHourMinute } from '../../utils/legacyDateTime'; import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress'; import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface GeneralStats { interface GeneralStats {
leadership: number; leadership: number;
@@ -19,9 +22,18 @@ interface GeneralProgression {
statUpgradeLimit?: number; statUpgradeLimit?: number;
} }
interface ItemDisplayNames {
horse?: string | null;
weapon?: string | null;
book?: string | null;
item?: string | null;
}
interface GeneralInfo { interface GeneralInfo {
id: number; id: number;
name: string; name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number; npcState: number;
officerLevel: number; officerLevel: number;
officerLevelText: string; officerLevelText: string;
@@ -35,17 +47,36 @@ interface GeneralInfo {
experience: number; experience: number;
dedication: number; dedication: number;
age?: number; age?: number;
turnTime?: string; turnTime?: string | null;
troopId?: number;
crewTypeId?: number; crewTypeId?: number;
crewTypeName?: string; crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string }; traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression; progression?: GeneralProgression;
itemNames?: ItemDisplayNames;
equipmentNames?: ItemDisplayNames;
} }
const props = defineProps<{ const props = withDefaults(
general: GeneralInfo | null; defineProps<{
loading: boolean; general: GeneralInfo | null;
}>(); loading: boolean;
nationColor?: string | null;
defenceText?: string | null;
killTurn?: number | null;
remainingMinutes?: number | null;
troopText?: string | null;
penaltyText?: string | number | null;
}>(),
{
nationColor: '#173d27',
defenceText: null,
killTurn: null,
remainingMinutes: null,
troopText: null,
penaltyText: null,
}
);
const statRows = computed(() => { const statRows = computed(() => {
const general = props.general; const general = props.general;
@@ -72,137 +103,320 @@ const statRows = computed(() => {
const experiencePercent = computed(() => const experiencePercent = computed(() =>
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0) legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
); );
const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {});
const generalIconBackground = computed(() => resolveGeneralIconBackgroundImage(props.general ?? {}));
const crewTypeIconBackground = computed(() => {
const crewTypeId = props.general?.crewTypeId;
if (crewTypeId === undefined || !Number.isFinite(crewTypeId)) {
return `url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
}
const crewTypeUrl = `${configuredGameAssetUrl()}/crewtype${Math.trunc(crewTypeId)}.png`;
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
});
const injuryInfo = computed(() => {
const injury = props.general?.injury ?? 0;
if (injury > 60) return { text: '위독', color: '#ff4d4f' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ff9f1a' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
});
const isBrightColor = (color: string): boolean => {
const normalized = /^#[0-9a-f]{6}$/iu.test(color) ? color.slice(1) : '173d27';
const red = Number.parseInt(normalized.slice(0, 2), 16);
const green = Number.parseInt(normalized.slice(2, 4), 16);
const blue = Number.parseInt(normalized.slice(4, 6), 16);
return (red * 299 + green * 587 + blue * 114) / 1000 >= 150;
};
const titleStyle = computed(() => {
const backgroundColor = props.nationColor || '#173d27';
return {
backgroundColor,
color: isBrightColor(backgroundColor) ? '#000000' : '#ffffff',
};
});
const ageColor = computed(() => {
const age = props.general?.age;
if (age === undefined) return '#ffffff';
if (age < 53) return '#32cd32';
if (age < 70) return '#ffff00';
return '#ff4d4f';
});
const displayTroop = computed(() => props.troopText ?? (props.general?.troopId ? String(props.general.troopId) : '-'));
const displayPenalty = computed(() => {
const penalty = props.penaltyText ?? '-';
const dedication = props.general?.progression?.dedicationText ?? '무품관';
return `${penalty} · 계급 ${dedication}`;
});
const displayDefence = computed(() => props.defenceText ?? '-');
const specialText = computed(() => {
const traits = props.general?.traits;
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
});
</script> </script>
<template> <template>
<div class="general-card"> <div class="general-card" data-general-basic-card>
<div v-if="props.loading"> <div v-if="props.loading" class="general-loading">
<SkeletonLines :lines="5" /> <SkeletonLines :lines="5" />
</div> </div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body"> <template v-else>
<div class="general-title"> <div class="general-basic-grid general-body">
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }} · <span
다음 class="general-image general-icon"
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }} role="img"
</div> :aria-label="`${props.general.name} 초상`"
:style="{ backgroundImage: generalIconBackground }"
/>
<div class="general-title battle-general-name" :style="titleStyle">
{{ props.general.name }} {{ props.general.officerLevelText }} |
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<div class="stat-progress-grid">
<template v-for="stat of statRows" :key="stat.key"> <template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span> <span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong> <strong class="stat-value">
<div class="bar-cell" :data-stat-progress="stat.key"> <span>{{ stat.value }}</span>
<LegacyProgressBar <span class="bar-cell" :data-stat-progress="stat.key">
:percent="stat.percent" <LegacyProgressBar
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`" :percent="stat.percent"
/> :label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
</div> />
</span>
</strong>
</template> </template>
</div>
<div class="legacy-grid"> <span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span <span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span <span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
><strong>{{ props.general.crewTypeName ?? '-' }}</strong> <span>성격</span
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
><strong>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
</div>
<div class="experience-row"> <span
<span class="cell-label">Lv</span> class="general-image general-crew-type-icon"
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong> role="img"
<div class="bar-cell" data-experience-progress> :aria-label="`${props.general.crewTypeName ?? '병종'} 이미지`"
:style="{ backgroundImage: crewTypeIconBackground }"
/>
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
<span class="cell-label level-label">Lv</span>
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<span class="experience-bar" data-experience-progress>
<LegacyProgressBar <LegacyProgressBar
:percent="experiencePercent" :percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`" :label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/> />
</div> </span>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span> <span class="cell-label age-label">연령</span>
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}</strong>
<span class="cell-label defence-label">수비</span>
<strong class="defence-value">{{ displayDefence }}</strong>
<span class="cell-label kill-label">삭턴</span>
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn}` }}</strong>
<span class="cell-label execute-label">실행</span>
<strong class="execute-value">{{
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
}}</strong>
<span class="cell-label troop-label">부대</span>
<strong class="troop-value">{{ displayTroop }}</strong>
<span class="cell-label penalty-label">벌점</span>
<strong class="penalty-value">{{ displayPenalty }}</strong>
</div> </div>
</div> <slot name="details" />
</template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.general-title { .general-card {
box-sizing: border-box; box-sizing: border-box;
height: 20px; width: 100%;
min-height: 20px; min-width: 0;
padding: 1px 6px;
border-bottom: 1px solid #777;
background: #173d27;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.stat-progress-grid {
display: grid;
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
grid-auto-rows: 21px;
font-size: 12px;
}
.stat-progress-grid > *,
.legacy-grid > * {
box-sizing: border-box;
height: 21px;
min-height: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
overflow: hidden; overflow: hidden;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
color: #fff;
font-size: 12px;
}
.general-basic-grid {
display: grid;
box-sizing: border-box;
width: 100%;
min-width: 0;
grid-template-columns: 64px repeat(3, minmax(30px, 2fr) minmax(60px, 5fr));
grid-template-rows: repeat(9, calc(64px / 3));
border-right: 1px solid #777;
border-bottom: 1px solid #777;
text-align: center;
}
.general-basic-grid > * {
box-sizing: border-box;
min-width: 0;
min-height: 0;
border-top: 1px solid #777;
border-left: 1px solid #777;
padding: 1px 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.cell-label, .general-basic-grid > strong {
.legacy-grid > span { font-weight: 500;
background: rgb(20 75 42 / 70%);
text-align: center; text-align: center;
} }
.stat-progress-grid > strong, .cell-label {
.legacy-grid > strong { background-color: rgb(20 75 42 / 70%);
text-align: right;
font-weight: 400;
} }
.bar-cell { .general-image {
display: block;
width: 64px;
height: 64px;
padding: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.general-icon {
grid-column: 1;
grid-row: 1 / 4;
}
.general-title {
grid-column: 2 / 8;
grid-row: 1;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.stat-value {
display: grid;
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
align-items: center;
gap: 2px;
}
.bar-cell,
.experience-bar {
display: grid; display: grid;
align-content: center; align-content: center;
padding: 0 1px; padding: 0 1px;
} }
.legacy-grid { .general-crew-type-icon {
display: grid; grid-column: 1;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-row: 4 / 7;
grid-auto-rows: 21px;
font-size: 12px;
} }
.experience-row { .level-label {
display: grid; grid-column: 1;
box-sizing: border-box; grid-row: 7;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
height: 20px;
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
} }
.experience-row > * { .level-value {
display: grid; grid-column: 2;
align-content: center; grid-row: 7;
box-sizing: border-box; }
border-right: 1px solid #666;
padding: 1px 4px; .experience-bar {
text-align: center; grid-column: 3 / 6;
grid-row: 7;
}
.age-label {
grid-column: 6;
grid-row: 7;
}
.age-value {
grid-column: 7;
grid-row: 7;
}
.defence-label {
grid-column: 1;
grid-row: 8;
}
.defence-value {
grid-column: 2 / 4;
grid-row: 8;
}
.kill-label {
grid-column: 4;
grid-row: 8;
}
.kill-value {
grid-column: 5;
grid-row: 8;
}
.execute-label {
grid-column: 6;
grid-row: 8;
}
.execute-value {
grid-column: 7;
grid-row: 8;
}
.troop-label {
grid-column: 1;
grid-row: 9;
}
.troop-value {
grid-column: 2 / 4;
grid-row: 9;
}
.penalty-label {
grid-column: 4;
grid-row: 9;
}
.penalty-value {
grid-column: 5 / 8;
grid-row: 9;
}
.general-loading,
.empty {
min-height: 192px;
padding: 8px;
} }
.empty { .empty {
@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
const props = defineProps<{
tournamentStage: number;
status: { status: {
onlineUserCount: number; onlineUserCount: number;
onlineNations: string; onlineNations: string;
@@ -13,15 +17,24 @@ defineProps<{
} | null; } | null;
} | null; } | null;
}>(); }>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
</script> </script>
<template> <template>
<section class="front-status" aria-label="접속 현황과 국가 방침"> <section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="status-row vote-status"> <div class="activity-status" aria-label="설문과 토너먼트 진행 현황">
<RouterLink v-if="status?.latestVote" to="/survey"> <div class="status-row tournament-status">
<span class="vote-label">설문 진행 : </span>{{ status.latestVote.title }} <RouterLink to="/tournament">
</RouterLink> <span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
<span v-else class="vote-empty">진행중인 설문 없음</span> </RouterLink>
</div>
<div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey">
<span class="vote-label">설문: </span>{{ status.latestVote.title }}
</RouterLink>
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
</div>
</div> </div>
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div> <div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
<div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div> <div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div>
@@ -71,19 +84,28 @@ defineProps<{
margin: 0; margin: 0;
} }
.vote-status { .activity-status {
width: 33.333333%; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 66.666667%;
margin-left: auto; margin-left: auto;
}
.activity-status .status-row {
padding-right: 0; padding-right: 0;
padding-left: 0; padding-left: 0;
text-align: center; text-align: center;
} }
.vote-status a { .activity-status a {
color: #fff; color: #fff;
text-decoration: gray underline; text-decoration: gray underline;
} }
.tournament-label {
color: #ffc107;
}
.vote-label { .vote-label {
color: cyan; color: cyan;
} }
@@ -93,8 +115,8 @@ defineProps<{
} }
@media (max-width: 991px) { @media (max-width: 991px) {
.vote-status { .activity-status {
width: 50%; width: 100%;
} }
} }
</style> </style>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { RouterLink } from 'vue-router';
interface MapCityView { interface MapCityView {
id: number; id: number;
name: string; name: string;
@@ -20,6 +21,7 @@ const props = defineProps<{
city: MapCityView; city: MapCityView;
showName: boolean; showName: boolean;
mapScale: number; mapScale: number;
selectOnly?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -31,12 +33,15 @@ const emit = defineEmits<{
const size = computed(() => (6 + props.city.level * 2) * props.mapScale); const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
const stateSize = computed(() => 8 * props.mapScale); const stateSize = computed(() => 8 * props.mapScale);
const stateOffset = computed(() => -6 * props.mapScale); const stateOffset = computed(() => -6 * props.mapScale);
const selectCity = () => emit('select', props.city.id);
</script> </script>
<template> <template>
<RouterLink <component
:is="props.selectOnly ? 'button' : RouterLink"
class="map-city" class="map-city"
:to="{ name: 'current-city', query: { cityId: props.city.id } }" :type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:class="[ :class="[
`state-${props.city.stateClass}`, `state-${props.city.stateClass}`,
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }, { mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
@@ -44,7 +49,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }" :style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)" @mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')" @mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)" @click.stop="selectCity"
> >
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"> <div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
<span v-if="props.city.isCapital" class="capital" /> <span v-if="props.city.isCapital" class="capital" />
@@ -61,7 +66,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
}" }"
/> />
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div> <div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</RouterLink> </component>
</template> </template>
<style scoped> <style scoped>
@@ -76,6 +81,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
color: rgba(232, 221, 196, 0.8); color: rgba(232, 221, 196, 0.8);
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
padding: 0;
border: 0;
background: transparent;
} }
.city-dot { .city-dot {
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets'; import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
interface MapCityView { interface MapCityView {
@@ -46,6 +47,7 @@ const props = defineProps<{
imageBaseUrl: string; imageBaseUrl: string;
themeName: string; themeName: string;
mapScale: number; mapScale: number;
selectOnly?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -141,6 +143,8 @@ const capitalIconStyle = computed(() => ({
height: `${10 * props.mapScale}px`, height: `${10 * props.mapScale}px`,
})); }));
const selectCity = () => emit('select', props.city.id);
const cityStateStyle = computed(() => ({ const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`, width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`, height: `${12 * props.mapScale}px`,
@@ -149,14 +153,16 @@ const cityStateStyle = computed(() => ({
</script> </script>
<template> <template>
<RouterLink <component
:is="props.selectOnly ? 'button' : RouterLink"
class="city-base" class="city-base"
:to="{ name: 'current-city', query: { cityId: props.city.id } }" :type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]" :class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
:style="cityBaseStyle" :style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)" @mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')" @mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)" @click.stop="selectCity"
> >
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" /> <div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
<div class="city-img" :style="cityIconStyle"> <div class="city-img" :style="cityIconStyle">
@@ -173,7 +179,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle"> <div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" /> <img :src="stateIcon" />
</div> </div>
</RouterLink> </component>
</template> </template>
<style scoped> <style scoped>
@@ -184,6 +190,9 @@ const cityStateStyle = computed(() => ({
color: #fff; color: #fff;
cursor: auto; cursor: auto;
text-decoration: none; text-decoration: none;
padding: 0;
border: 0;
background: transparent;
} }
.city-bg { .city-bg {
@@ -67,6 +67,13 @@ const props = defineProps<{
mapData: MapSummary | null; mapData: MapSummary | null;
mapLayout: MapLayout | null; mapLayout: MapLayout | null;
loading: boolean; loading: boolean;
selectedCityId?: number | null;
detailMode?: boolean;
fitContainer?: boolean;
}>();
const emit = defineEmits<{
(event: 'select-city', cityId: number): void;
}>(); }>();
const BASE_MAP_WIDTH = 700; const BASE_MAP_WIDTH = 700;
@@ -75,7 +82,12 @@ const SMALL_MAP_SCALE = 5 / 7;
const isWide = useMediaQuery('(min-width: 1024px)'); const isWide = useMediaQuery('(min-width: 1024px)');
const mapStore = useMapViewerStore(); const mapStore = useMapViewerStore();
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore); const {
showCityName,
detailMode: storeDetailMode,
hoveredCityId,
selectedCityId: storeSelectedCityId,
} = storeToRefs(mapStore);
const mapArea = ref<HTMLElement | null>(null); const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null); const mapBody = ref<HTMLElement | null>(null);
@@ -140,15 +152,20 @@ const dynamicCityById = computed(() => {
}); });
const mapScale = computed(() => { const mapScale = computed(() => {
if (isWide.value) { if (isWide.value && !props.fitContainer) {
return 1; return 1;
} }
if (mapBodyWidth.value <= 0) { if (mapBodyWidth.value <= 0) {
return SMALL_MAP_SCALE; return SMALL_MAP_SCALE;
} }
return Math.min(SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH); return Math.min(props.fitContainer ? 1 : SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
}); });
const effectiveDetailMode = computed(() => props.detailMode ?? storeDetailMode.value);
const effectiveSelectedCityId = computed(() =>
props.selectedCityId === undefined ? storeSelectedCityId.value : props.selectedCityId
);
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`); const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`); const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
@@ -185,7 +202,7 @@ const cityViews = computed<CityView[]>(() => {
y, y,
isCapital: nation?.capitalCityId === layoutCity.id, isCapital: nation?.capitalCityId === layoutCity.id,
isMyCity: props.mapData?.myCity === layoutCity.id, isMyCity: props.mapData?.myCity === layoutCity.id,
selected: selectedCityId.value === layoutCity.id, selected: effectiveSelectedCityId.value === layoutCity.id,
}; };
}); });
}); });
@@ -258,7 +275,7 @@ const titleTooltipLines = computed(() => {
}); });
const titleBandStyle = computed(() => const titleBandStyle = computed(() =>
detailMode.value effectiveDetailMode.value
? { ? {
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`, backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
} }
@@ -266,7 +283,7 @@ const titleBandStyle = computed(() =>
); );
const titleTextStyle = computed(() => const titleTextStyle = computed(() =>
detailMode.value effectiveDetailMode.value
? { ? {
color: titleColor.value, color: titleColor.value,
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`, backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
@@ -327,7 +344,7 @@ const mapRoadStyle = computed(() => ({
})); }));
const detailProps = computed(() => const detailProps = computed(() =>
detailMode.value effectiveDetailMode.value
? { ? {
imageBaseUrl: assetBaseUrl.value, imageBaseUrl: assetBaseUrl.value,
themeName: mapTheme.value, themeName: mapTheme.value,
@@ -365,7 +382,10 @@ const setHoveredCity = (cityId: number | null) => {
}; };
const selectCity = (cityId: number) => { const selectCity = (cityId: number) => {
mapStore.setSelectedCity(cityId); emit('select-city', cityId);
if (props.selectedCityId === undefined) {
mapStore.setSelectedCity(cityId);
}
}; };
</script> </script>
@@ -394,12 +414,13 @@ const selectCity = (cityId: number) => {
<div class="map-layer map-bglayer2" /> <div class="map-layer map-bglayer2" />
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" /> <div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
<component <component
:is="detailMode ? MapCityDetail : MapCityBasic" :is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
v-for="city in cityViews" v-for="city in cityViews"
:key="city.id" :key="city.id"
:city="city" :city="city"
:map-scale="mapScale" :map-scale="mapScale"
:show-name="showCityName" :show-name="showCityName"
:select-only="props.selectedCityId !== undefined"
v-bind="detailProps" v-bind="detailProps"
@hover="setHoveredCity" @hover="setHoveredCity"
@leave="setHoveredCity(null)" @leave="setHoveredCity(null)"
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import GeneralIdentity from '../ui/GeneralIdentity.vue';
import { import {
buildTournamentBracket, buildTournamentBracket,
type TournamentBracketMatch, type TournamentBracketMatch,
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
:class="{ advanced: bracket.champion.advanced }" :class="{ advanced: bracket.champion.advanced }"
:data-general-id="bracket.champion.id ?? undefined" :data-general-id="bracket.champion.id ?? undefined"
> >
{{ bracket.champion.name }} <GeneralIdentity
:name="bracket.champion.name"
:picture="bracket.champion.picture"
:image-server="bracket.champion.imageServer"
:icon-size="24"
/>
</span> </span>
</div> </div>
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }" :class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined" :data-general-id="slot.id ?? undefined"
> >
{{ slot.name }} <GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="22"
/>
</span> </span>
</div> </div>
<div class="connector-row" :style="{ '--connector-count': round.slots.length }"> <div class="connector-row" :style="{ '--connector-count': round.slots.length }">
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }" :class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined" :data-general-id="slot.id ?? undefined"
> >
{{ slot.name }} <GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="20"
/>
</span> </span>
</div> </div>
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)"> <div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
@@ -183,9 +199,17 @@ const odds = (id: number | null) => {
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`" :key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
class="mobile-bracket-name" class="mobile-bracket-name"
:class="{ advanced: slot.advanced }" :class="{ advanced: slot.advanced }"
:style="{ left: `${mobileX[columnIndex]}px`, top: `${mobileY(columnIndex, slotIndex)}px` }" :style="{
left: `${(mobileX[columnIndex]! / 390) * 100}%`,
top: `${mobileY(columnIndex, slotIndex)}px`,
}"
> >
{{ slot.name }} <GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="18"
/>
</span> </span>
</template> </template>
</div> </div>
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
white-space: nowrap; white-space: nowrap;
} }
.bracket-canvas { .bracket-canvas {
width: 2000px; width: 100%;
min-width: 2000px; min-width: 1000px;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.mobile-bracket { .mobile-bracket {
position: relative; position: relative;
display: none; display: none;
width: 390px; width: 100%;
max-width: 390px;
height: 544px; height: 544px;
margin: 0 auto; margin: 0 auto;
} }
.mobile-bracket svg { .mobile-bracket svg {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 390px; width: 100%;
height: 544px; height: 544px;
} }
.mobile-connector { .mobile-connector {
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
.mobile-bracket-name { .mobile-bracket-name {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
width: 64px; width: clamp(58px, 18vw, 72px);
overflow: hidden; overflow: hidden;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border: 1px solid #555; border: 1px solid #555;
background: rgb(58 33 24 / 92%); background: rgb(58 33 24 / 92%);
color: #fff; color: #fff;
font-size: 12px; min-height: 26px;
line-height: 22px; padding: 2px;
font-size: 11px;
line-height: 20px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
} }
.bracket-name { .bracket-name {
overflow: hidden; overflow: hidden;
padding: 0 3px; padding: 2px 3px;
color: #fff; color: #fff;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.tournament-bracket { .tournament-bracket {
width: 100vw; width: 100%;
max-width: 100vw; max-width: 100%;
overflow-x: hidden; overflow-x: hidden;
} }
.bracket-canvas { .bracket-canvas {
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { resolveGeneralIconUrl, useDefaultGeneralIcon, type GeneralIconSource } from '../../utils/generalIcon';
const props = withDefaults(
defineProps<{
name: string;
picture?: GeneralIconSource['picture'];
imageServer?: GeneralIconSource['imageServer'];
iconSize?: number;
hideIcon?: boolean;
}>(),
{
picture: null,
imageServer: 0,
iconSize: 28,
hideIcon: false,
}
);
const iconUrl = computed(() =>
resolveGeneralIconUrl({
picture: props.picture,
imageServer: props.imageServer,
})
);
const identityStyle = computed(() => ({ '--general-identity-icon-size': `${props.iconSize}px` }));
</script>
<template>
<span class="general-identity" :style="identityStyle">
<img
v-if="!hideIcon && name !== '-'"
class="general-identity-icon"
:src="iconUrl"
alt=""
aria-hidden="true"
@error="useDefaultGeneralIcon"
/>
<span class="general-identity-name">{{ name }}</span>
</span>
</template>
<style scoped>
.general-identity {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 5px;
vertical-align: middle;
}
.general-identity-icon {
width: var(--general-identity-icon-size);
height: var(--general-identity-icon-size);
flex: 0 0 var(--general-identity-icon-size);
border: 1px solid rgb(255 255 255 / 28%);
background: #111;
object-fit: cover;
}
.general-identity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -15,7 +15,9 @@ type GeneralProgress = {
}; };
}; };
const props = defineProps<{ general: GeneralProgress }>(); const props = withDefaults(defineProps<{ general: GeneralProgress; showPrimary?: boolean }>(), {
showPrimary: true,
});
const statRows = computed(() => const statRows = computed(() =>
[ [
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
<template> <template>
<div class="legacy-general-progress"> <div class="legacy-general-progress">
<div class="stat-grid"> <div v-if="props.showPrimary" class="stat-grid">
<template v-for="stat of statRows" :key="stat.key"> <template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span> <span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong> <strong>{{ stat.value }}</strong>
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
/> />
</template> </template>
</div> </div>
<div class="experience-row"> <div v-if="props.showPrimary" class="experience-row">
<span class="cell-label">Lv</span> <span class="cell-label">Lv</span>
<strong>{{ props.general.progression.experienceLevel }}</strong> <strong>{{ props.general.progression.experienceLevel }}</strong>
<LegacyProgressBar <LegacyProgressBar
+4 -22
View File
@@ -1,24 +1,6 @@
const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000; import { formatServerDateTime } from '@sammo-ts/common';
const pad = (value: number): string => String(value).padStart(2, '0'); export const formatSeoulDateTime = (value: string | Date): string => formatServerDateTime(value);
export const formatSeoulDateTime = (value: string | Date): string => { export const formatSeoulHourMinute = (value: string | Date): string =>
if ( formatServerDateTime(value, { format: 'hourMinute' });
typeof value === 'string' &&
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
) {
return value.trim().replace('T', ' ').slice(0, 19);
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return typeof value === 'string' ? value.slice(0, 19) : '';
}
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
};
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
@@ -0,0 +1,299 @@
export type NationGeneralColumnId =
| 'icon'
| 'name'
| 'officerLevel'
| 'expDedLv_1'
| 'dedlevel'
| 'explevel'
| 'stat_1'
| 'leadership'
| 'strength'
| 'intel'
| 'troop'
| 'goldRice_1'
| 'gold'
| 'rice'
| 'city'
| 'crew'
| 'specials_1'
| 'personal'
| 'specialDomestic'
| 'specialWar'
| 'years_1'
| 'belong'
| 'killturnAndRefresh_1'
| 'refreshScoreTotal';
export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh';
export type SortDirection = 'asc' | 'desc';
export type NationGeneralViewMode = 'normal' | 'war';
export type NationGeneralColumnState = {
colId: NationGeneralColumnId;
width: number;
hide: boolean;
sort: SortDirection | null;
sortIndex?: number;
};
export type NationGeneralGroupState = {
groupId: NationGeneralGroupId;
open: boolean;
};
export type NationGeneralDisplaySetting = {
column: NationGeneralColumnState[];
columnGroup: NationGeneralGroupState[];
};
export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string];
export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting';
export const DISPLAY_SETTINGS_VERSION = 1;
export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`;
const baseColumns = (): NationGeneralColumnState[] => [
{ colId: 'icon', width: 80, hide: false, sort: null },
{ colId: 'name', width: 126, hide: false, sort: null },
{ colId: 'officerLevel', width: 70, hide: false, sort: null },
{ colId: 'expDedLv_1', width: 60, hide: false, sort: null },
{ colId: 'dedlevel', width: 70, hide: false, sort: null },
{ colId: 'explevel', width: 60, hide: false, sort: null },
{ colId: 'stat_1', width: 88, hide: false, sort: null },
{ colId: 'leadership', width: 60, hide: false, sort: null },
{ colId: 'strength', width: 60, hide: false, sort: null },
{ colId: 'intel', width: 60, hide: false, sort: null },
{ colId: 'troop', width: 90, hide: true, sort: null },
{ colId: 'goldRice_1', width: 80, hide: false, sort: null },
{ colId: 'gold', width: 70, hide: false, sort: null },
{ colId: 'rice', width: 70, hide: false, sort: null },
{ colId: 'city', width: 60, hide: true, sort: null },
{ colId: 'crew', width: 70, hide: true, sort: null },
{ colId: 'specials_1', width: 80, hide: false, sort: null },
{ colId: 'personal', width: 60, hide: false, sort: null },
{ colId: 'specialDomestic', width: 60, hide: false, sort: null },
{ colId: 'specialWar', width: 60, hide: false, sort: null },
{ colId: 'years_1', width: 60, hide: false, sort: null },
{ colId: 'belong', width: 60, hide: false, sort: null },
{ colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null },
{ colId: 'refreshScoreTotal', width: 70, hide: false, sort: null },
];
const groupState = (overrides: Partial<Record<NationGeneralGroupId, boolean>>): NationGeneralGroupState[] =>
(['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({
groupId,
open: overrides[groupId] ?? false,
}));
const withColumnOverrides = (
columns: NationGeneralColumnState[],
overrides: Partial<Record<NationGeneralColumnId, Partial<NationGeneralColumnState>>>
): NationGeneralColumnState[] =>
columns.map((column) => ({
...column,
...overrides[column.colId],
}));
export const defaultNationGeneralDisplaySettings: Record<NationGeneralViewMode, NationGeneralDisplaySetting> = {
normal: {
column: withColumnOverrides(baseColumns(), {
troop: { hide: true },
city: { hide: true },
crew: { hide: true },
refreshScoreTotal: { sort: 'desc', sortIndex: 0 },
}),
columnGroup: groupState({
expDedLv: true,
stat: true,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
}),
},
war: {
column: withColumnOverrides(baseColumns(), {
icon: { hide: true },
officerLevel: { hide: true },
expDedLv_1: { hide: true },
dedlevel: { hide: true },
explevel: { hide: true },
troop: { hide: false },
city: { hide: false },
crew: { hide: false },
specials_1: { hide: true },
personal: { hide: true },
specialDomestic: { hide: true },
specialWar: { hide: true },
years_1: { hide: true },
belong: { hide: true },
killturnAndRefresh_1: { hide: true },
refreshScoreTotal: { hide: true },
}),
columnGroup: groupState({
expDedLv: false,
stat: false,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
}),
},
};
export const cloneNationGeneralDisplaySetting = (
setting: NationGeneralDisplaySetting
): NationGeneralDisplaySetting => ({
column: setting.column.map((column) => ({ ...column })),
columnGroup: setting.columnGroup.map((group) => ({ ...group })),
});
const validColumnIds = new Set<NationGeneralColumnId>(baseColumns().map((column) => column.colId));
const validGroupIds = new Set<NationGeneralGroupId>(groupState({}).map((group) => group.groupId));
const isSortDirection = (value: unknown): value is SortDirection => value === 'asc' || value === 'desc';
export const normalizeNationGeneralDisplaySetting = (raw: unknown): NationGeneralDisplaySetting | null => {
if (!raw || typeof raw !== 'object') {
return null;
}
const candidate = raw as { column?: unknown; columnGroup?: unknown };
if (!Array.isArray(candidate.column) || !Array.isArray(candidate.columnGroup)) {
return null;
}
const fallback = cloneNationGeneralDisplaySetting(defaultNationGeneralDisplaySettings.normal);
const rawColumns = new Map<string, Record<string, unknown>>();
for (const value of candidate.column) {
if (!value || typeof value !== 'object') continue;
const column = value as Record<string, unknown>;
if (typeof column.colId === 'string' && validColumnIds.has(column.colId as NationGeneralColumnId)) {
rawColumns.set(column.colId, column);
}
}
fallback.column = fallback.column.map((column) => {
const saved = rawColumns.get(column.colId);
if (!saved) return column;
return {
...column,
width: typeof saved.width === 'number' && saved.width > 0 ? saved.width : column.width,
hide: typeof saved.hide === 'boolean' ? saved.hide : column.hide,
sort: isSortDirection(saved.sort) ? saved.sort : null,
...(typeof saved.sortIndex === 'number' && saved.sortIndex >= 0
? { sortIndex: Math.trunc(saved.sortIndex) }
: {}),
};
});
const rawGroups = new Map<string, boolean>();
for (const value of candidate.columnGroup) {
if (!value || typeof value !== 'object') continue;
const group = value as Record<string, unknown>;
if (
typeof group.groupId === 'string' &&
validGroupIds.has(group.groupId as NationGeneralGroupId) &&
typeof group.open === 'boolean'
) {
rawGroups.set(group.groupId, group.open);
}
}
fallback.columnGroup = fallback.columnGroup.map((group) => ({
...group,
open: rawGroups.get(group.groupId) ?? group.open,
}));
return fallback;
};
export const parseStoredDisplaySettings = (raw: string | null): Map<string, NationGeneralDisplaySetting> => {
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as { version?: unknown; settings?: unknown };
if (parsed.version !== DISPLAY_SETTINGS_VERSION || !Array.isArray(parsed.settings)) return new Map();
const result = new Map<string, NationGeneralDisplaySetting>();
for (const entry of parsed.settings) {
if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue;
const setting = normalizeNationGeneralDisplaySetting(entry[1]);
if (setting) result.set(entry[0], setting);
}
return result;
} catch {
return new Map();
}
};
export const serializeDisplaySettings = (settings: Map<string, NationGeneralDisplaySetting>): string =>
JSON.stringify({
version: DISPLAY_SETTINGS_VERSION,
settings: [...settings.entries()],
});
export const parseStoredSettingKey = (raw: string | null): NationGeneralSettingKey | null => {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (
!Array.isArray(parsed) ||
parsed.length !== 2 ||
typeof parsed[0] !== 'boolean' ||
typeof parsed[1] !== 'string'
) {
return null;
}
if (parsed[0]) return parsed[1] === 'normal' || parsed[1] === 'war' ? [true, parsed[1]] : null;
return [false, parsed[1]];
} catch {
return null;
}
};
const initialConsonants = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const hangulInitials = (value: string): string =>
[...value]
.map((character) => {
const code = character.charCodeAt(0);
if (code < 0xac00 || code > 0xd7a3) return character;
return initialConsonants[Math.floor((code - 0xac00) / 588)] ?? character;
})
.join('');
const normalizeSearchText = (value: string): string => value.toLocaleLowerCase('ko-KR').replace(/\s+/g, '');
export const matchesKoreanSearch = (value: string, query: string): boolean => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return true;
const normalizedValue = normalizeSearchText(value);
return (
normalizedValue.includes(normalizedQuery) ||
normalizeSearchText(hangulInitials(value)).includes(normalizedQuery)
);
};
export const matchesNumberSearch = (value: number | null, query: string): boolean => {
const normalized = query.trim();
if (!normalized) return true;
if (value === null || !Number.isFinite(value)) return false;
const match = /^(<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)$/.exec(normalized);
if (!match) return false;
const expected = Number(match[2]);
switch (match[1] ?? '=') {
case '<':
return value < expected;
case '<=':
return value <= expected;
case '>':
return value > expected;
case '>=':
return value >= expected;
default:
return value === expected;
}
};
export const compareGridValues = (left: string | number | null, right: string | number | null): number => {
if (left === right) return 0;
if (left === null) return 1;
if (right === null) return -1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), 'ko-KR', { numeric: true });
};
@@ -1,6 +1,8 @@
export interface TournamentBracketParticipant { export interface TournamentBracketParticipant {
id: number; id: number;
name: string; name: string;
picture?: string | null;
imageServer?: number | null;
} }
export interface TournamentBracketMatch { export interface TournamentBracketMatch {
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
export interface TournamentBracketSlot { export interface TournamentBracketSlot {
id: number | null; id: number | null;
name: string; name: string;
picture: string | null;
imageServer: number;
advanced: boolean; advanced: boolean;
} }
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
top16: TournamentBracketRound; top16: TournamentBracketRound;
} }
const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false }); const emptySlot = (): TournamentBracketSlot => ({
id: null,
name: '-',
picture: null,
imageServer: 0,
advanced: false,
});
export const buildTournamentBracket = ( export const buildTournamentBracket = (
participants: TournamentBracketParticipant[], participants: TournamentBracketParticipant[],
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
winnerId?: number winnerId?: number
): TournamentBracketModel => { ): TournamentBracketModel => {
const participantsById = new Map(participants.map((participant) => [participant.id, participant])); const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
const nameOf = (id: number | null): string => const participantOf = (id: number | null): TournamentBracketParticipant | null =>
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`); id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => { const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
const roundMatches = matches const roundMatches = matches
.filter((match) => match.stage === stage) .filter((match) => match.stage === stage)
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id); .sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) => const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
[match.attackerId, match.defenderId].map((id) => ({ [match.attackerId, match.defenderId].map((id) => {
id, const participant = participantOf(id);
name: nameOf(id), return {
advanced: match.winnerId === id, id,
})) name: participant?.name ?? '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
advanced: match.winnerId === id,
};
})
); );
while (slots.length < slotCount) { while (slots.length < slotCount) {
slots.push(emptySlot()); slots.push(emptySlot());
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
return { return {
champion: { champion: {
id: resolvedWinnerId, id: resolvedWinnerId,
name: nameOf(resolvedWinnerId), name: participantOf(resolvedWinnerId)?.name ?? '-',
picture: participantOf(resolvedWinnerId)?.picture ?? null,
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
advanced: resolvedWinnerId !== null, advanced: resolvedWinnerId !== null,
}, },
final, final,
@@ -0,0 +1,15 @@
export const tournamentStageNames = [
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
] as const;
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
+5 -18
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -41,24 +42,10 @@ const formatNumber = (value: number | null | undefined): string => (value ?? 0).
const displayCode = (value: string | null | undefined): string => const displayCode = (value: string | null | undefined): string =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, ''); !value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => { const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) { return formatServerDateTime(value, {
return '-'; format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
} fallback: '-',
const date = new Date(value); });
if (Number.isNaN(date.getTime())) {
return value.slice(5, showSecond ? 19 : 16);
}
const parts = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
...(showSecond ? { second: '2-digit' } : {}),
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((entry) => entry.type === type)?.value ?? '';
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
}; };
const buyRice = computed(() => const buyRice = computed(() =>
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>; type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number]; type GeneralEntry = BattleCenterResponse['generals'][number];
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
const formatGeneralLabel = (general: GeneralEntry): string => { const formatGeneralLabel = (general: GeneralEntry): string => {
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name; const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
const time = general.turnTime ? general.turnTime.slice(-5) : '--:--'; const time = formatServerDateTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' });
if (orderBy.value === 'recentWar') { if (orderBy.value === 'recentWar') {
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`; return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
} }
if (orderBy.value === 'warnum') { if (orderBy.value === 'warnum') {
return `${name} (${general.warnum}회)`; return `${name} (${general.warnum}회)`;
@@ -136,8 +137,6 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
return `${name} (${time})`; return `${name} (${time})`;
}; };
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
let logRequestId = 0; let logRequestId = 0;
const loadLogs = async (generalId: number) => { const loadLogs = async (generalId: number) => {
@@ -156,7 +155,10 @@ const loadLogs = async (generalId: number) => {
} }
for (const response of responses) { for (const response of responses) {
const formatted = response.logs.map((entry) => { const formatted = response.logs.map((entry) => {
const eventTime = response.type === 'generalAction' ? ` ${entry.createdAt.slice(-8, -3)}` : ''; const eventTime =
response.type === 'generalAction'
? ` ${formatServerDateTime(entry.createdAt, { format: 'hourMinute' })}`
: '';
return { return {
id: entry.id, id: entry.id,
html: formatLog(`${entry.text}${eventTime}`), html: formatLog(`${entry.text}${eventTime}`),
@@ -266,49 +268,36 @@ onMounted(() => {
</PanelCard> </PanelCard>
<PanelCard title="장수 정보"> <PanelCard title="장수 정보">
<SkeletonLines v-if="loading" :lines="5" /> <GeneralBasicCard
<div v-else-if="selectedGeneral" class="battle-general-card"> class="battle-general-card"
<div class="battle-general-name"> :general="selectedGeneral"
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }}) :loading="loading"
</div> :nation-color="data?.nation.color"
<span >
class="battle-general-portrait" <template v-if="selectedGeneral" #details>
role="img" <div class="battle-general-extra">
:aria-label="`${selectedGeneral.name} 초상`" <span>명성</span
:style="{ backgroundImage: `url(${generalImageUrl(selectedGeneral)})` }" ><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
/> <span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<div class="battle-general-grid"> <span>전투</span><strong>{{ selectedGeneral.warnum }}</strong> <span>승리</span
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span ><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span ><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span ><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span ><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span <span>피살</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span ><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span <span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span </div>
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span <LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span </template>
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span </GeneralBasicCard>
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
<div class="battle-general-extra">
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>나이</span><strong>{{ selectedGeneral.age }}</strong> <span>병종</span
><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
<span>피살</span
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong>
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" />
</div>
<div v-if="selectedGeneral" class="general-meta"> <div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div> <div>
최근 :
{{
formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' })
}}
</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div> <div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
<div>전투 횟수: {{ selectedGeneral.warnum }}</div> <div>전투 횟수: {{ selectedGeneral.warnum }}</div>
</div> </div>
@@ -375,39 +364,7 @@ onMounted(() => {
gap: 4px; gap: 4px;
} }
.battle-general-card {
min-height: 292px;
position: relative;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
}
.battle-general-portrait {
display: block;
width: 64px;
height: 80px;
float: left;
background-position: center;
background-size: cover;
}
.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(6, 1fr);
}
.battle-general-extra { .battle-general-extra {
clear: both;
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);
} }
@@ -433,24 +390,6 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.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 { .log-grid {
display: contents; display: contents;
} }
+199 -75
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>; type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -12,6 +14,7 @@ const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const message = ref<string | null>(null); const message = ref<string | null>(null);
const amounts = ref<Record<number, number>>({}); const amounts = ref<Record<number, number>>({});
const activeRankingPrefix = ref('tt');
const typeNames = ['전력전', '통솔전', '일기토', '설전']; const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [ const stageNames = [
'경기 없음', '경기 없음',
@@ -57,7 +60,13 @@ const final16Ids = computed(() =>
const candidates = computed(() => const candidates = computed(() =>
Array.from({ length: 16 }, (_, index) => { Array.from({ length: 16 }, (_, index) => {
const id = final16Ids.value[index] ?? 0; const id = final16Ids.value[index] ?? 0;
return { id, name: id ? (participantMap.value.get(id)?.name ?? `#${id}`) : '-' }; const participant = id ? participantMap.value.get(id) : null;
return {
id,
name: id ? (participant?.name ?? `#${id}`) : '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
};
}) })
); );
const totalAmount = computed(() => summary.value?.totalAmount ?? 0); const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
@@ -68,7 +77,9 @@ const ratio = (id: number) => {
const amount = totals?.[id] ?? 0; const amount = totals?.[id] ?? 0;
return amount ? (totalAmount.value / amount).toFixed(2) : '0'; return amount ? (totalAmount.value / amount).toFixed(2) : '0';
}; };
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--'); const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const expected = (id: number) => { const expected = (id: number) => {
const myTotals = summary.value?.myTotals as Record<number, number> | undefined; const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
const current = myTotals?.[id] ?? 0; const current = myTotals?.[id] ?? 0;
@@ -129,58 +140,43 @@ const placeBet = async (targetId: number) => {
:bet-totals="betTotals" :bet-totals="betTotals"
:total-bet="totalAmount" :total-bet="totalAmount"
:show-legend="false" :show-legend="false"
force-desktop
/> />
<section class="candidate-table bg0"> <section class="candidate-table bg0">
<div class="candidate-row names"> <div class="candidate-grid">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span> <article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
<GeneralIdentity
:name="candidate.name"
:picture="candidate.picture"
:image-server="candidate.imageServer"
:icon-size="36"
/>
<div class="candidate-return">
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
<span aria-hidden="true">×</span>
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
<span aria-hidden="true">=</span>
<strong class="return-color">{{ expected(candidate.id) }}</strong>
</div>
<div v-if="bettingOpen" class="candidate-actions">
<select
v-model.number="amounts[candidate.id]"
:aria-label="`${candidate.name} 베팅 금액`"
:disabled="!candidate.id"
>
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대</option>
</select>
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
</div>
</article>
</div> </div>
<div class="candidate-row ratios"> <p class="candidate-help">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
ratio(candidate.id)
}}</span>
</div>
<div class="candidate-row multiply">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">×</span>
</div>
<div class="candidate-row labels">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name"></span>
</div>
<div class="candidate-row expected">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
expected(candidate.id)
}}</span>
</div>
<div v-if="bettingOpen" class="candidate-row selects">
<select
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
v-model.number="amounts[candidate.id]"
:aria-label="`${candidate.name} 베팅 금액`"
:disabled="!candidate.id"
>
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대</option>
</select>
</div>
<div v-if="bettingOpen" class="candidate-row buttons">
<button
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
type="button"
:disabled="!candidate.id"
@click="placeBet(candidate.id)"
>
베팅!
</button>
</div>
<p>
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> = <span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
<span class="return-color">적중시 환수금</span><br /> <span class="return-color">적중시 환수금</span><br />
<span class="ratio-color">( 베팅후 500 이하일땐 베팅이 불가능합니다. )</span> <span class="ratio-color">( 베팅후 500 이하일땐 베팅이 불가능합니다. )</span>
@@ -201,8 +197,26 @@ const placeBet = async (targetId: number) => {
<section class="ranking-placeholder bg0"> <section class="ranking-placeholder bg0">
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수 순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
</section> </section>
<div class="ranking-tabs bg0" role="tablist" aria-label="토너먼트 랭킹 종목 선택">
<button
v-for="section in rankings"
:key="`ranking-tab-${section.prefix}`"
type="button"
role="tab"
:aria-selected="activeRankingPrefix === section.prefix"
:class="{ active: activeRankingPrefix === section.prefix }"
@click="activeRankingPrefix = section.prefix"
>
{{ section.title.replaceAll(' ', '') }}
</button>
</div>
<section class="ranking-grid bg0"> <section class="ranking-grid bg0">
<table v-for="section in rankings" :key="section.prefix" class="ranking-table"> <table
v-for="section in rankings"
:key="section.prefix"
class="ranking-table"
:class="{ 'mobile-active': activeRankingPrefix === section.prefix }"
>
<thead> <thead>
<tr> <tr>
<th colspan="9">{{ section.title }}</th> <th colspan="9">{{ section.title }}</th>
@@ -222,7 +236,14 @@ const placeBet = async (targetId: number) => {
<tbody> <tbody>
<tr v-for="entry in section.entries" :key="entry.generalId"> <tr v-for="entry in section.entries" :key="entry.generalId">
<td>{{ entry.rank }}</td> <td>{{ entry.rank }}</td>
<td>{{ entry.name }}</td> <td class="ranking-general">
<GeneralIdentity
:name="entry.name"
:picture="entry.picture"
:image-server="entry.imageServer"
:icon-size="24"
/>
</td>
<td>{{ entry.stat }}</td> <td>{{ entry.stat }}</td>
<td>{{ entry.games }}</td> <td>{{ entry.games }}</td>
<td>{{ entry.win }}</td> <td>{{ entry.win }}</td>
@@ -248,8 +269,7 @@ const placeBet = async (targetId: number) => {
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
HideD(hided62@gmail.com) / Credit
</small> </small>
</footer> </footer>
</main> </main>
@@ -257,9 +277,10 @@ const placeBet = async (targetId: number) => {
<style scoped> <style scoped>
.betting-page { .betting-page {
width: 1125px; width: 100%;
height: 1346px; max-width: 1200px;
overflow: hidden; min-width: 0;
min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font-family: var(--sammo-font-sans); font-family: var(--sammo-font-sans);
@@ -268,8 +289,8 @@ const placeBet = async (targetId: number) => {
text-align: center; text-align: center;
} }
.betting-bracket :deep(.bracket-canvas) { .betting-bracket :deep(.bracket-canvas) {
width: 1125px; width: 100%;
min-width: 1125px; min-width: 1000px;
} }
.betting-bracket :deep(.bracket-round), .betting-bracket :deep(.bracket-round),
.betting-bracket :deep(.connector-row) { .betting-bracket :deep(.connector-row) {
@@ -351,20 +372,34 @@ const placeBet = async (targetId: number) => {
} }
.candidate-table { .candidate-table {
border: 1px solid gray; border: 1px solid gray;
padding: 10px 0; padding: 10px;
font-size: 10px; font-size: 12px;
} }
.candidate-row { .candidate-grid {
display: grid; display: grid;
grid-template-columns: repeat(16, 70px); grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center; gap: 8px;
min-height: 10px;
line-height: 10px;
} }
.names { .candidate-card {
min-height: 14px; min-width: 0;
padding: 8px;
border: 1px solid #5b504b;
background: rgb(0 0 0 / 26%);
text-align: left;
}
.candidate-return {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
gap: 4px;
margin: 8px 0;
text-align: center;
font-variant-numeric: tabular-nums;
}
.candidate-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
gap: 6px;
} }
.ratios,
.ratio-color { .ratio-color {
color: skyblue; color: skyblue;
} }
@@ -376,7 +411,7 @@ const placeBet = async (targetId: number) => {
color: orange; color: orange;
} }
select, select,
.buttons button { .candidate-actions button {
width: 100%; width: 100%;
min-height: 27px; min-height: 27px;
padding: 2px 1px; padding: 2px 1px;
@@ -410,7 +445,7 @@ select:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.candidate-table p { .candidate-help {
min-height: 20px; min-height: 20px;
margin: 8px 0 0; margin: 8px 0 0;
font-size: 18px; font-size: 18px;
@@ -429,11 +464,13 @@ select:disabled {
} }
.ranking-grid { .ranking-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 280px); grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start; align-items: start;
gap: 8px;
padding: 8px;
} }
.ranking-table { .ranking-table {
width: 280px; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 12px; font-size: 12px;
@@ -441,7 +478,7 @@ select:disabled {
} }
.ranking-table th, .ranking-table th,
.ranking-table td { .ranking-table td {
height: 14px; height: 28px;
padding: 1px; padding: 1px;
border: 1px solid #555; border: 1px solid #555;
} }
@@ -455,12 +492,20 @@ select:disabled {
.ranking-table .bg1 { .ranking-table .bg1 {
background: #213b52; background: #213b52;
} }
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) { .ranking-table td:nth-child(2) {
max-width: 80px; width: 130px;
max-width: 130px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.ranking-general {
text-align: left;
}
.ranking-tabs {
display: none;
}
.guide { .guide {
padding: 10px; padding: 10px;
text-align: left; text-align: left;
@@ -468,4 +513,83 @@ select:disabled {
.error { .error {
color: #ff8080; color: #ff8080;
} }
@media (max-width: 800px) {
.betting-page {
max-width: 100%;
font-size: 13px;
}
.title {
height: auto;
min-height: 55px;
}
.state {
font-size: 18px;
}
.section-title,
.ranking-title {
font-size: 20px;
}
.candidate-grid {
grid-template-columns: 1fr;
}
.candidate-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: center;
gap: 8px 12px;
}
.candidate-return {
margin: 0;
}
.candidate-actions {
grid-column: 1 / -1;
}
.candidate-help {
font-size: 14px;
line-height: 18px;
}
.ranking-placeholder {
display: none;
}
.ranking-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
padding: 8px;
}
.ranking-tabs button {
height: 36px;
margin: 0;
border-radius: 3px;
}
.ranking-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
}
.ranking-grid {
display: block;
overflow-x: auto;
padding: 0;
}
.ranking-table {
display: none;
min-width: 390px;
font-size: 11px;
}
.ranking-table.mobile-active {
display: table;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
width: 112px;
max-width: 112px;
}
.guide,
.betting-footer {
padding: 10px;
}
.betting-footer small {
white-space: normal;
}
}
</style> </style>
+13 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
element.style.height = `${Math.max(element.scrollHeight, 42)}px`; element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
}; };
const formatDate = (value: string): string => value.slice(5, 16).replace('T', ' '); const formatDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const iconPath = (article: BoardArticle): string => const iconPath = (article: BoardArticle): string =>
resolveGeneralIconUrl({ resolveGeneralIconUrl({
@@ -160,7 +161,14 @@ onMounted(() => {
</div> </div>
<div class="article-submit-row"> <div class="article-submit-row">
<div></div> <div></div>
<button id="submitArticle" class="legacy-button legacy-button--secondary" type="button" @click="submitArticle">등록</button> <button
id="submitArticle"
class="legacy-button legacy-button--secondary"
type="button"
@click="submitArticle"
>
등록
</button>
</div> </div>
</section> </section>
@@ -244,7 +252,9 @@ onMounted(() => {
padding: 8px; padding: 8px;
color: #000; color: #000;
background: #fff; background: #fff;
font: 16px/normal 'Times New Roman', serif; font:
16px/normal 'Times New Roman',
serif;
} }
.legacy-board-page { .legacy-board-page {
@@ -8,7 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue'; import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat'; import { formatOfficerLevelText } from '../utils/nationFormat';
import type { CommandPatternEntry, CommandTable } from '../components/command/types'; import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
type ChiefTurn = { type ChiefTurn = {
index: number; index: number;
@@ -54,6 +54,10 @@ const chiefApi = trpc as unknown as {
query: (input: { generalId: number }) => Promise<CommandTable>; query: (input: { generalId: number }) => Promise<CommandTable>;
}; };
}; };
world: {
getMap: { query: () => Promise<CommandMapData> };
getMapLayout: { query: () => Promise<CommandMapLayout> };
};
}; };
type TurnRow = { type TurnRow = {
@@ -71,6 +75,8 @@ const commandLoading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const data = ref<ChiefCenterResponse | null>(null); const data = ref<ChiefCenterResponse | null>(null);
const commandTable = ref<CommandTable | null>(null); const commandTable = ref<CommandTable | null>(null);
const worldMap = ref<CommandMapData | null>(null);
const mapLayout = ref<CommandMapLayout | null>(null);
const selectedChiefLevel = ref<number | null>(null); const selectedChiefLevel = ref<number | null>(null);
const router = useRouter(); const router = useRouter();
@@ -109,7 +115,14 @@ const loadCommandTable = async (generalId: number) => {
} }
commandLoading.value = true; commandLoading.value = true;
try { try {
commandTable.value = await chiefApi.turns.getCommandTable.query({ generalId }); const [nextCommandTable, nextWorldMap, nextMapLayout] = await Promise.all([
chiefApi.turns.getCommandTable.query({ generalId }),
chiefApi.world.getMap.query().catch(() => null),
chiefApi.world.getMapLayout.query().catch(() => null),
]);
commandTable.value = nextCommandTable;
worldMap.value = nextWorldMap;
mapLayout.value = nextMapLayout;
} catch (err) { } catch (err) {
error.value = resolveErrorMessage(err); error.value = resolveErrorMessage(err);
} finally { } finally {
@@ -317,6 +330,8 @@ const repeatTurns = async (amount: number) => {
:general-id="data.me.id" :general-id="data.me.id"
:officer-level="selectedChief.officerLevel" :officer-level="selectedChief.officerLevel"
:mobile="true" :mobile="true"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns" @reserve-bulk="reserveTurns"
@shift="shiftTurns" @shift="shiftTurns"
@repeat="repeatTurns" @repeat="repeatTurns"
@@ -377,6 +392,8 @@ const repeatTurns = async (amount: number) => {
:loading="commandLoading" :loading="commandLoading"
:general-id="data.me.id" :general-id="data.me.id"
:officer-level="chief.officerLevel" :officer-level="chief.officerLevel"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns" @reserve-bulk="reserveTurns"
@shift="shiftTurns" @shift="shiftTurns"
@repeat="repeatTurns" @repeat="repeatTurns"
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
} }
}; };
const formatArchiveDate = (value: string): string => const formatArchiveDate = (value: string): string => formatServerDateTime(value);
new Intl.DateTimeFormat('sv-SE', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
watch(emperorId, loadDetail); watch(emperorId, loadDetail);
onMounted(loadDetail); onMounted(loadDetail);
@@ -67,7 +63,9 @@ onMounted(loadDetail);
<br /> <br />
<button class="native-button" type="button" @click="closePage"> 닫기</button> <button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link"> <span class="all-link">
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink> <RouterLink to="/dynasty"
><button class="native-button" type="button">전체보기</button></RouterLink
>
</span> </span>
</td> </td>
</tr> </tr>
@@ -202,7 +200,11 @@ onMounted(loadDetail);
<td colspan="5"> <td colspan="5">
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. --> <!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" /> <div
v-for="(entry, index) in data.emperor.history"
:key="index"
v-html="formatLog(entry)"
/>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -283,14 +285,10 @@ onMounted(loadDetail);
<table class="legacy-table legacy-bg0 footer-table"> <table class="legacy-table legacy-bg0 footer-table">
<tbody> <tbody>
<tr> <tr>
<td> <td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
+3 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
if (!turnTimeResult.value) { if (!turnTimeResult.value) {
return null; return null;
} }
const parsed = new Date(turnTimeResult.value); return formatServerDateTime(turnTimeResult.value);
if (Number.isNaN(parsed.getTime())) {
return turnTimeResult.value;
}
return parsed.toLocaleString();
}); });
const isUnited = computed(() => status.value?.isUnited ?? false); const isUnited = computed(() => status.value?.isUnited ?? false);
@@ -735,7 +732,7 @@ onMounted(() => {
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div> <div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div> <div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<div v-for="entry in logs" v-else :key="entry.id" class="log-row"> <div v-for="entry in logs" v-else :key="entry.id" class="log-row">
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small> <small>[{{ formatServerDateTime(entry.createdAt) }}]</small>
<span>{{ entry.text }}</span> <span>{{ entry.text }}</span>
</div> </div>
<button <button
+24 -17
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core'; import { useMediaQuery } from '@vueuse/core';
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => { const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text); if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
const parsed = entry.createdAt ? new Date(entry.createdAt) : null; const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text); if (!time) return formatLog(entry.text);
const time = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(parsed);
return formatLog(`${entry.text} ${time}`); return formatLog(`${entry.text} ${time}`);
}; };
@@ -148,13 +143,9 @@ watch(
<header class="game-shell__header"> <header class="game-shell__header">
<div> <div>
<h1 class="game-shell__title"> <h1 class="game-shell__title">
{{ isMobile ? '전장 현황' : lobbyInfo?.scenarioTitle || '전장 현황' }} {{ lobbyInfo?.scenarioTitle || '전장 현황' }}
</h1> </h1>
<p class="game-shell__subtitle"> <p class="game-shell__subtitle">{{ statusLine }}</p>
{{
!isMobile && lobbyInfo?.scenarioTitle ? `${lobbyInfo.scenarioTitle} ${statusLine}` : statusLine
}}
</p>
</div> </div>
<div class="game-shell__actions desktop-action-controls"> <div class="game-shell__actions desktop-action-controls">
<button <button
@@ -197,7 +188,7 @@ watch(
</div> </div>
<div data-main-target="policy"> <div data-main-target="policy">
<MainFrontStatus :status="frontStatus" /> <MainFrontStatus :status="frontStatus" :tournament-stage="tournamentStage" />
</div> </div>
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite"> <aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
@@ -220,6 +211,8 @@ watch(
:current-month="lobbyInfo?.month" :current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm" :turn-term-minutes="lobbyInfo?.turnTerm"
:autorun-limit="reservedGeneralAutorunLimit" :autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@set-general-turns="reserveGeneralTurns" @set-general-turns="reserveGeneralTurns"
@shift-general-turns="shiftGeneralTurns" @shift-general-turns="shiftGeneralTurns"
@repeat-general-turns="repeatGeneralTurns" @repeat-general-turns="repeatGeneralTurns"
@@ -241,7 +234,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" /> <NationBasicCard :nation="nation" :loading="loading" />
</PanelCard> </PanelCard>
<PanelCard title="장수 스탯" data-main-target="general"> <PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" /> <GeneralBasicCard
:general="general"
:loading="loading"
:nation-color="nation?.color"
:troop-text="general?.troopId ? String(general.troopId) : '-'"
/>
</PanelCard> </PanelCard>
<PanelCard title="도시 정보" data-main-target="city"> <PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" /> <CityBasicCard :city="city" :loading="loading" />
@@ -344,6 +342,8 @@ watch(
:current-month="lobbyInfo?.month" :current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm" :turn-term-minutes="lobbyInfo?.turnTerm"
:autorun-limit="reservedGeneralAutorunLimit" :autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@set-general-turns="reserveGeneralTurns" @set-general-turns="reserveGeneralTurns"
@shift-general-turns="shiftGeneralTurns" @shift-general-turns="shiftGeneralTurns"
@repeat-general-turns="repeatGeneralTurns" @repeat-general-turns="repeatGeneralTurns"
@@ -356,7 +356,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" /> <NationBasicCard :nation="nation" :loading="loading" />
</PanelCard> </PanelCard>
<PanelCard title="장수 스탯" data-main-target="general"> <PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" /> <GeneralBasicCard
:general="general"
:loading="loading"
:nation-color="nation?.color"
:troop-text="general?.troopId ? String(general.troopId) : '-'"
/>
</PanelCard> </PanelCard>
<MainNationMenu <MainNationMenu
class="nation-menu-middle" class="nation-menu-middle"
@@ -605,12 +610,14 @@ button {
.layout-desktop > [data-main-target='nation'] { .layout-desktop > [data-main-target='nation'] {
grid-column: 1 / 6; grid-column: 1 / 6;
grid-row: 3; grid-row: 3;
align-self: stretch;
min-height: 193px; min-height: 193px;
} }
.layout-desktop > [data-main-target='general'] { .layout-desktop > [data-main-target='general'] {
grid-column: 6 / 11; grid-column: 6 / 11;
grid-row: 3; grid-row: 3;
align-self: stretch;
min-height: 193px; min-height: 193px;
} }
+97 -130
View File
@@ -7,6 +7,7 @@ import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
const SCREEN_MODE_KEY = 'sam.screenMode'; const SCREEN_MODE_KEY = 'sam.screenMode';
@@ -167,6 +168,7 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
] ]
); );
const iconChoices = computed(() => data.value?.iconChoices ?? []); const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user)); const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false); const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
@@ -360,7 +362,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`"> <main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<div class="title-row"> <div class="title-row">
<span> </span> <span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink> <RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
@@ -374,91 +376,43 @@ onMounted(() => {
<section class="top-grid"> <section class="top-grid">
<div class="general-column"> <div class="general-column">
<div class="section-title sky">장수 정보</div> <div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div> <GeneralBasicCard
<div v-else class="general-table"> class="general-table"
<div class="portrait-cell"> :general="data?.general ?? null"
<span :loading="loading"
class="portrait-image" :nation-color="data?.nation?.color"
role="img" :defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }" :troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
></span> :penalty-text="penalties.length || '-'"
<strong>{{ data.general.name }}</strong> >
</div> <template v-if="data" #details>
<dl> <div class="legacy-general-details">
<div> <div>
<dt>통솔</dt> 명망
<dd>{{ data.general.stats.leadership }}</dd> <strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
</div>
<div>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div>
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
</div>
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
</div> </div>
<div> </template>
<dt>무력</dt> </GeneralBasicCard>
<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>
<div>
<dt>성격/특기</dt>
<dd>
{{ data.general.traits?.personal ?? '-' }} /
{{ data.general.traits?.specialWar ?? '-' }}
</dd>
</div>
<div>
<dt>나이/다음턴</dt>
<dd>{{ data.general.age ?? '-' }} / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
</div>
</dl>
</div>
<div v-if="data" class="legacy-general-details">
<div>
명망
<strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
</div>
<div>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div>
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
</div>
<LegacyGeneralProgress :general="data.general" />
</div>
</div> </div>
<div class="settings-column"> <div class="settings-column">
@@ -541,6 +495,16 @@ onMounted(() => {
<span v-if="data.iconChangeAvailableAt" class="hint"> <span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }} 다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span> </span>
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
<img
:src="resolveGeneralIconUrl(selectedIcon)"
width="48"
height="48"
alt=""
@error="useDefaultGeneralIcon"
/>
<strong>{{ data.general.name }}</strong>
</div>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택"> <div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice"> <label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" /> <input v-model="selectedIconId" type="radio" :value="icon.id" />
@@ -675,8 +639,7 @@ onMounted(() => {
.legacy-page { .legacy-page {
width: 100%; width: 100%;
max-width: 1000px; max-width: 1000px;
min-width: 500px; min-width: 0;
height: 1257.5px;
min-height: 0; min-height: 0;
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
@@ -786,28 +749,6 @@ button:disabled {
.sky { .sky {
color: skyblue; color: skyblue;
} }
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
}
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-image {
display: block;
width: 64px;
height: 64px;
background-position: center;
background-size: cover;
}
.legacy-general-info-compat { .legacy-general-info-compat {
display: none; display: none;
} }
@@ -824,23 +765,6 @@ button:disabled {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
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 { .settings-column {
padding: 10px 18px; padding: 10px 18px;
} }
@@ -942,6 +866,21 @@ dt {
gap: 6px; gap: 6px;
margin: 6px 0; margin: 6px 0;
} }
.selected-general-icon {
display: flex;
max-width: 260px;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
padding: 6px 10px;
border: 1px solid #666;
background: rgb(23 42 82 / 70%);
}
.selected-general-icon img {
flex: 0 0 48px;
object-fit: cover;
}
.general-icon-choice { .general-icon-choice {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -949,16 +888,44 @@ dt {
} }
@media (max-width: 991px) { @media (max-width: 991px) {
.legacy-page { .legacy-page {
width: 500px; width: 100%;
height: 1798.34px; max-width: 100%;
} }
.my-page-mobile-scroll-spacer { .my-page-mobile-scroll-spacer {
display: block; display: none;
height: 100px;
} }
.top-grid, .top-grid,
.log-grid { .log-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 600px) {
.title-row {
height: auto;
min-height: 54px;
}
dl > div {
grid-template-columns: 62px minmax(0, 1fr);
}
dt,
dd {
padding: 2px 3px;
}
.settings-column {
padding: 10px 12px;
}
.screen-mode-row {
grid-template-columns: 1fr;
gap: 6px;
}
.button-group {
overflow-x: auto;
}
.item-group {
grid-template-columns: repeat(2, 1fr);
}
.custom-css textarea {
width: 100%;
}
}
</style> </style>
+661 -171
View File
@@ -1,28 +1,202 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useMediaQuery } from '@vueuse/core';
import { formatOfficerLevelText } from '../utils/nationFormat'; import { formatOfficerLevelText } from '../utils/nationFormat';
import { resolveGeneralIconUrl } from '../utils/generalIcon'; import { resolveGeneralIconUrl } from '../utils/generalIcon';
import {
DISPLAY_SETTINGS_KEY,
cloneNationGeneralDisplaySetting,
compareGridValues,
defaultNationGeneralDisplaySettings,
lastUsedSettingsKey,
matchesKoreanSearch,
matchesNumberSearch,
parseStoredDisplaySettings,
parseStoredSettingKey,
serializeDisplaySettings,
type NationGeneralColumnId,
type NationGeneralColumnState,
type NationGeneralDisplaySetting,
type NationGeneralGroupId,
type NationGeneralSettingKey,
} from '../utils/nationGeneralGrid';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>; type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number]; type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; type CellValue = string | number | null;
type ColumnDefinition = {
id: NationGeneralColumnId;
label: string;
width: number;
groupId?: NationGeneralGroupId;
summary?: boolean;
sortable?: boolean;
searchable?: 'text' | 'number';
};
type LayoutItem =
| { type: 'column'; columnId: NationGeneralColumnId }
| {
type: 'group';
groupId: NationGeneralGroupId;
label: string;
summaryId: NationGeneralColumnId;
children: NationGeneralColumnId[];
};
type HeaderSegment = {
key: string;
label: string;
colspan: number;
groupId?: NationGeneralGroupId;
open?: boolean;
};
const columns: ColumnDefinition[] = [
{ id: 'icon', label: '아이콘', width: 80 },
{ id: 'name', label: '장수명', width: 126, sortable: true, searchable: 'text' },
{ id: 'officerLevel', label: '관직', width: 70, sortable: true, searchable: 'text' },
{ id: 'expDedLv_1', label: '', width: 60, groupId: 'expDedLv', summary: true },
{ id: 'dedlevel', label: '계급', width: 70, groupId: 'expDedLv', sortable: true, searchable: 'number' },
{ id: 'explevel', label: '명성', width: 60, groupId: 'expDedLv', sortable: true, searchable: 'number' },
{ id: 'stat_1', label: '통|무|지', width: 88, groupId: 'stat', summary: true },
{ id: 'leadership', label: '통솔', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'strength', label: '무력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'intel', label: '지력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'troop', label: '부대', width: 90, sortable: true, searchable: 'text' },
{ id: 'goldRice_1', label: '금/쌀', width: 80, groupId: 'goldRice', summary: true, sortable: true },
{ id: 'gold', label: '금', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
{ id: 'rice', label: '쌀', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
{ id: 'city', label: '도시', width: 60, sortable: true, searchable: 'text' },
{ id: 'crew', label: '병력', width: 70, sortable: true, searchable: 'number' },
{ id: 'specials_1', label: '요약', width: 80, groupId: 'specials', summary: true },
{ id: 'personal', label: '성격', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
{
id: 'specialDomestic',
label: '내특',
width: 60,
groupId: 'specials',
sortable: true,
searchable: 'text',
},
{ id: 'specialWar', label: '전특', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
{ id: 'years_1', label: '요약', width: 60, groupId: 'years', summary: true },
{ id: 'belong', label: '사관', width: 60, groupId: 'years', sortable: true, searchable: 'number' },
{ id: 'killturnAndRefresh_1', label: '벌점', width: 70, groupId: 'killturnAndRefresh', summary: true },
{
id: 'refreshScoreTotal',
label: '벌점',
width: 70,
groupId: 'killturnAndRefresh',
sortable: true,
searchable: 'number',
},
];
const layout: LayoutItem[] = [
{ type: 'column', columnId: 'icon' },
{ type: 'column', columnId: 'name' },
{ type: 'column', columnId: 'officerLevel' },
{
type: 'group',
groupId: 'expDedLv',
label: '명성/계급',
summaryId: 'expDedLv_1',
children: ['dedlevel', 'explevel'],
},
{
type: 'group',
groupId: 'stat',
label: '능력치',
summaryId: 'stat_1',
children: ['leadership', 'strength', 'intel'],
},
{ type: 'column', columnId: 'troop' },
{
type: 'group',
groupId: 'goldRice',
label: '자금',
summaryId: 'goldRice_1',
children: ['gold', 'rice'],
},
{ type: 'column', columnId: 'city' },
{ type: 'column', columnId: 'crew' },
{
type: 'group',
groupId: 'specials',
label: '특성',
summaryId: 'specials_1',
children: ['personal', 'specialDomestic', 'specialWar'],
},
{ type: 'group', groupId: 'years', label: '연도', summaryId: 'years_1', children: ['belong'] },
{
type: 'group',
groupId: 'killturnAndRefresh',
label: '기타',
summaryId: 'killturnAndRefresh_1',
children: ['refreshScoreTotal'],
},
];
const columnById = new Map(columns.map((column) => [column.id, column]));
const data = ref<Result | null>(null); const data = ref<Result | null>(null);
const router = useRouter(); const router = useRouter();
const error = ref(''); const error = ref('');
const loading = ref(false); const loading = ref(false);
const sort = ref<Sort>(1);
const viewMenuOpen = ref(false); const viewMenuOpen = ref(false);
const columnMenuOpen = ref(false); const columnMenuOpen = ref(false);
const isNarrow = useMediaQuery('(max-width: 1000px)'); const currentSetting = ref<NationGeneralSettingKey>([true, 'normal']);
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55)); const displaySettings = ref(new Map<string, NationGeneralDisplaySetting>());
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42)); const columnState = ref<NationGeneralColumnState[]>([]);
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16)); const groupState = ref<Record<NationGeneralGroupId, boolean>>({
const nameFilter = ref(''); expDedLv: true,
const officerFilter = ref(''); stat: true,
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null); goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
});
const filters = ref<Partial<Record<NationGeneralColumnId, string>>>({});
const applyDisplaySetting = (settingKey: NationGeneralSettingKey, setting: NationGeneralDisplaySetting) => {
const cloned = cloneNationGeneralDisplaySetting(setting);
columnState.value = cloned.column;
groupState.value = Object.fromEntries(cloned.columnGroup.map((group) => [group.groupId, group.open])) as Record<
NationGeneralGroupId,
boolean
>;
currentSetting.value = settingKey;
viewMenuOpen.value = false;
};
const loadDisplaySettings = () => {
displaySettings.value = parseStoredDisplaySettings(localStorage.getItem(DISPLAY_SETTINGS_KEY));
const lastUsed = parseStoredSettingKey(localStorage.getItem(lastUsedSettingsKey('pageNationGeneral')));
if (lastUsed?.[0]) {
applyDisplaySetting(lastUsed, defaultNationGeneralDisplaySettings[lastUsed[1]]);
return;
}
if (lastUsed && !lastUsed[0]) {
const stored = displaySettings.value.get(lastUsed[1]);
if (stored) {
applyDisplaySetting(lastUsed, stored);
return;
}
}
applyDisplaySetting([true, 'normal'], defaultNationGeneralDisplaySettings.normal);
};
loadDisplaySettings();
watch(displaySettings, (settings) => localStorage.setItem(DISPLAY_SETTINGS_KEY, serializeDisplaySettings(settings)), {
deep: true,
});
watch(currentSetting, (setting) =>
localStorage.setItem(lastUsedSettingsKey('pageNationGeneral'), JSON.stringify(setting))
);
const load = async () => { const load = async () => {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
@@ -34,36 +208,266 @@ const load = async () => {
loading.value = false; loading.value = false;
} }
}; };
const generals = computed(() =>
[...(data.value?.generals ?? [])] const stateById = computed(() => new Map(columnState.value.map((column) => [column.colId, column])));
.filter( const isColumnVisible = (columnId: NationGeneralColumnId): boolean => !(stateById.value.get(columnId)?.hide ?? true);
(general) =>
general.name.includes(nameFilter.value.trim()) && const activeColumnIds = computed<NationGeneralColumnId[]>(() => {
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes( const active: NationGeneralColumnId[] = [];
officerFilter.value.trim() for (const item of layout) {
) if (item.type === 'column') {
) if (isColumnVisible(item.columnId)) active.push(item.columnId);
.sort((a, b) => { continue;
if (sort.value === 1) return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id; }
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id; if (groupState.value[item.groupId]) {
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id; active.push(...item.children.filter(isColumnVisible));
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id; } else if (isColumnVisible(item.summaryId)) {
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id; active.push(item.summaryId);
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id; }
if (sort.value === 7) return b.gold - a.gold || a.id - b.id; }
if (sort.value === 8) return b.rice - a.rice || a.id - b.id; return active;
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id; });
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? ''); const activeColumns = computed(() =>
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? ''); activeColumnIds.value.map((columnId) => columnById.get(columnId)).filter((column) => column !== undefined)
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
return a.id - b.id;
})
); );
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
const tableWidth = computed(() =>
Math.max(
1000,
activeColumns.value.reduce((sum, column) => sum + column.width, 0)
)
);
const headerSegments = computed<HeaderSegment[]>(() => {
const segments: HeaderSegment[] = [];
for (const item of layout) {
if (item.type === 'column') {
if (activeColumnIds.value.includes(item.columnId)) {
segments.push({ key: item.columnId, label: '', colspan: 1 });
}
continue;
}
const visibleIds = groupState.value[item.groupId]
? item.children.filter((columnId) => activeColumnIds.value.includes(columnId))
: activeColumnIds.value.includes(item.summaryId)
? [item.summaryId]
: [];
if (visibleIds.length) {
segments.push({
key: item.groupId,
label: item.label,
colspan: visibleIds.length,
groupId: item.groupId,
open: groupState.value[item.groupId],
});
}
}
return segments;
});
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const officerText = (general: General): string => {
const title = formatOfficerLevelText(general.officerLevel, data.value?.nation.level);
return general.officerCityName && general.officerLevel >= 2 && general.officerLevel <= 4
? `${general.officerCityName}\n${title}`
: title;
};
const protectedText = (value: string | null): string => value ?? (data.value?.viewer.permission ? '-' : '?');
const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'name':
return general.name;
case 'officerLevel':
return officerText(general);
case 'expDedLv_1':
return `Lv ${general.experienceLevel}\n${general.dedicationText}`;
case 'dedlevel':
return `${general.dedicationText}\n(${general.bill.toLocaleString()})`;
case 'explevel':
return `Lv ${general.experienceLevel}\n(${general.personality?.name ?? '-'})`;
case 'stat_1':
return `${general.stats.leadership}|${general.stats.strength}|${general.stats.intelligence}`;
case 'leadership':
return general.stats.leadership;
case 'strength':
return general.stats.strength;
case 'intel':
return general.stats.intelligence;
case 'troop':
return protectedText(general.troopName);
case 'goldRice_1':
return `${general.gold.toLocaleString()}\n${general.rice.toLocaleString()}`;
case 'gold':
return general.gold;
case 'rice':
return general.rice;
case 'city':
return protectedText(general.cityName);
case 'crew':
return visibleCrew(general);
case 'specials_1':
return `${general.personality?.name ?? '-'}\n${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
case 'personal':
return general.personality?.name ?? '-';
case 'specialDomestic':
return general.specialDomestic?.name ?? '-';
case 'specialWar':
return general.specialWar?.name ?? '-';
case 'years_1':
return `${general.belong}`;
case 'belong':
return general.belong;
case 'killturnAndRefresh_1':
case 'refreshScoreTotal':
return Number(general.refreshScoreTotal);
case 'icon':
return null;
}
};
const filterValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'officerLevel':
return officerText(general);
case 'dedlevel':
return general.dedicationLevel;
case 'explevel':
return general.experienceLevel;
default:
return cellValue(general, columnId);
}
};
const sortValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'name':
return `${String(general.npcState).padStart(3, '0')}:${general.name}`;
case 'officerLevel':
return general.officerLevel;
case 'dedlevel':
return general.dedicationLevel;
case 'explevel':
return general.experienceLevel;
case 'goldRice_1':
return general.gold + general.rice;
default:
return cellValue(general, columnId);
}
};
const generals = computed(() => {
const filtered = [...(data.value?.generals ?? [])].filter((general) =>
Object.entries(filters.value).every(([rawColumnId, query]) => {
if (!query) return true;
const columnId = rawColumnId as NationGeneralColumnId;
const column = columnById.get(columnId);
const value = filterValue(general, columnId);
if (column?.searchable === 'number')
return matchesNumberSearch(typeof value === 'number' ? value : null, query);
return matchesKoreanSearch(value === null ? '' : String(value), query);
})
);
const sorts = columnState.value
.filter((column): column is NationGeneralColumnState & { sort: 'asc' | 'desc' } => column.sort !== null)
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
return filtered.sort((left, right) => {
for (const sort of sorts) {
const compared = compareGridValues(sortValue(left, sort.colId), sortValue(right, sort.colId));
if (compared) return sort.sort === 'asc' ? compared : -compared;
}
return left.id - right.id;
});
});
const setDisplayMode = (mode: 'normal' | 'war') =>
applyDisplaySetting([true, mode], defaultNationGeneralDisplaySettings[mode]);
const currentDisplaySetting = (): NationGeneralDisplaySetting => ({
column: columnState.value.map((column) => ({ ...column })),
columnGroup: Object.entries(groupState.value).map(([groupId, open]) => ({
groupId: groupId as NationGeneralGroupId,
open,
})),
});
const storeDisplaySetting = () => {
const defaultName = currentSetting.value[0] ? '' : currentSetting.value[1];
const nickname = window.prompt('선택한 설정의 별명을 지어주세요', defaultName)?.trim();
if (!nickname) return;
if (displaySettings.value.has(nickname) && !window.confirm('이미 있는 이름입니다. 덮어쓸까요?')) return;
const next = new Map(displaySettings.value);
const setting = currentDisplaySetting();
next.set(nickname, setting);
displaySettings.value = next;
currentSetting.value = [false, nickname];
};
const deleteDisplaySetting = (key: string) => {
if (!window.confirm(`${key} 설정을 지울까요?`)) return;
const next = new Map(displaySettings.value);
next.delete(key);
displaySettings.value = next;
if (!currentSetting.value[0] && currentSetting.value[1] === key) setDisplayMode('normal');
};
const toggleGroup = (groupId: NationGeneralGroupId) => {
groupState.value = { ...groupState.value, [groupId]: !groupState.value[groupId] };
};
const toggleColumn = (columnId: NationGeneralColumnId) => {
columnState.value = columnState.value.map((column) =>
column.colId === columnId ? { ...column, hide: !column.hide } : column
);
};
const nextSort = (columnId: NationGeneralColumnId, current: 'asc' | 'desc' | null): 'asc' | 'desc' | null => {
const order: ('asc' | 'desc' | null)[] = columnId === 'name' ? ['asc', 'desc', null] : ['desc', 'asc', null];
const index = order.indexOf(current);
return order[(index + 1) % order.length] ?? null;
};
const sortColumn = (columnId: NationGeneralColumnId, event: MouseEvent) => {
const definition = columnById.get(columnId);
if (!definition?.sortable) return;
const current = stateById.value.get(columnId)?.sort ?? null;
const next = nextSort(columnId, current);
const existingSortIndex = stateById.value.get(columnId)?.sortIndex;
const maxSortIndex = Math.max(-1, ...columnState.value.map((column) => column.sortIndex ?? -1));
columnState.value = columnState.value.map((column) => {
if (column.colId === columnId) {
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
return next
? { ...withoutSortIndex, sort: next, sortIndex: existingSortIndex ?? maxSortIndex + 1 }
: { ...withoutSortIndex, sort: null };
}
if (event.shiftKey) return column;
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
return { ...withoutSortIndex, sort: null };
});
};
const sortIndicator = (columnId: NationGeneralColumnId): string => {
const column = stateById.value.get(columnId);
if (!column?.sort) return '';
const order = column.sortIndex === undefined ? '' : `${column.sortIndex + 1}`;
return `${column.sort === 'asc' ? '▲' : '▼'}${order}`;
};
const iconUrl = (general: General) => resolveGeneralIconUrl(general); const iconUrl = (general: General) => resolveGeneralIconUrl(general);
const cellTitle = (general: General, columnId: NationGeneralColumnId): string => {
if (columnId === 'personal') return general.personality?.info ?? '';
if (columnId === 'specialDomestic') return general.specialDomestic?.info ?? '';
if (columnId === 'specialWar') return general.specialWar?.info ?? '';
if (columnId === 'specials_1') {
return [general.personality?.info, general.specialDomestic?.info, general.specialWar?.info]
.filter(Boolean)
.join('\n');
}
return '';
};
onMounted(load); onMounted(load);
</script> </script>
@@ -80,40 +484,68 @@ onMounted(load);
<button <button
class="top-button mode-button" class="top-button mode-button"
:aria-expanded="viewMenuOpen" :aria-expanded="viewMenuOpen"
@click="viewMenuOpen = !viewMenuOpen" @click="
viewMenuOpen = !viewMenuOpen;
columnMenuOpen = false;
"
> >
보기 모드 보기 모드
</button> </button>
<span v-if="viewMenuOpen" class="dropdown-menu"> <span v-if="viewMenuOpen" class="dropdown-menu view-mode-list">
<button <button @click="setDisplayMode('normal')">기본</button>
@click=" <button @click="setDisplayMode('war')">전투</button>
sort = 1; <span class="menu-divider"></span>
viewMenuOpen = false; <button @click="storeDisplaySetting">🔖&nbsp;보관하기</button>
" <template v-if="displaySettings.size">
> <span class="menu-divider"></span>
기본 <span v-for="[key, setting] in displaySettings" :key="key" class="saved-setting">
</button> <button class="saved-setting-name" @click="applyDisplaySetting([false, key], setting)">
<button {{ key }}
@click=" </button>
sort = 4; <button
viewMenuOpen = false; class="saved-setting-delete"
" :aria-label="`${key} 설정 삭제`"
> @click.stop="deleteDisplaySetting(key)"
전투 >
</button> 삭제
</button>
</span>
</template>
</span> </span>
</span> </span>
<span class="dropdown"> <span class="dropdown">
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen"> <button
class="top-button columns-button"
:aria-expanded="columnMenuOpen"
@click="
columnMenuOpen = !columnMenuOpen;
viewMenuOpen = false;
"
>
선택 선택
</button> </button>
<span v-if="columnMenuOpen" class="dropdown-menu column-menu"> <span v-if="columnMenuOpen" class="dropdown-menu column-menu">
<label <template v-for="item in layout" :key="item.type === 'column' ? item.columnId : item.groupId">
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']" <label v-if="item.type === 'column' && item.columnId !== 'name'">
:key="label" <input
> type="checkbox"
<input type="checkbox" checked /> {{ label }} :checked="isColumnVisible(item.columnId)"
</label> @change="toggleColumn(item.columnId)"
/>
{{ columnById.get(item.columnId)?.label }}
</label>
<template v-else-if="item.type === 'group'">
<span class="column-group-label">{{ item.label }}</span>
<label v-for="columnId in item.children" :key="columnId" class="child-column">
<input
type="checkbox"
:checked="isColumnVisible(columnId)"
@change="toggleColumn(columnId)"
/>
{{ columnById.get(columnId)?.label }}
</label>
</template>
</template>
</span> </span>
</span> </span>
</span> </span>
@@ -121,100 +553,99 @@ onMounted(load);
<p v-if="error" class="state error" role="alert">{{ error }}</p> <p v-if="error" class="state error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p> <p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="grid-shell"> <div v-else class="grid-shell">
<table id="nation-general-list"> <table id="nation-general-list" :style="{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }">
<colgroup> <colgroup>
<col <col v-for="column in activeColumns" :key="column.id" :style="{ width: `${column.width}px` }" />
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
:key="index"
:style="{ width: `${width}px` }"
/>
</colgroup> </colgroup>
<thead> <thead>
<tr class="group-head"> <tr class="group-head">
<th colspan="2"></th> <th v-for="segment in headerSegments" :key="segment.key" :colspan="segment.colspan">
<th></th> <button
<th>명성/계급&#x3000;</th> v-if="segment.groupId"
<th colspan="3">능력치&#x3000;</th> class="group-toggle"
<th colspan="2">자금&#x3000;</th> :aria-expanded="segment.open"
<th colspan="2">특성&#x3000;</th> :aria-label="`${segment.label} ${segment.open ? '접기' : '펼치기'}`"
<th>연도&#x3000;</th> @click="toggleGroup(segment.groupId)"
<th>기타&#x3000;</th> >
{{ segment.label }}&#x3000;{{ segment.open ? '' : '' }}
</button>
</th>
</tr> </tr>
<tr> <tr>
<th>아이콘</th> <th v-for="column in activeColumns" :key="column.id">
<th>장수명</th> <button
<th>관직</th> class="sort-button"
<th>계급</th> :class="{ sortable: column.sortable }"
<th>명성</th> :disabled="!column.sortable"
<th>통솔</th> :aria-label="column.sortable ? `${column.label} 정렬` : undefined"
<th>무력</th> @click="sortColumn(column.id, $event)"
<th>지력</th> >
<th></th> {{ column.label }}
<th v-if="!isNarrow"></th> <span class="sort-indicator">{{ sortIndicator(column.id) }}</span>
<th v-if="!isNarrow">요약</th> </button>
<th v-if="!isNarrow">요약</th> </th>
<th v-if="!isNarrow">벌점 </th>
</tr> </tr>
<tr class="filter-head"> <tr class="filter-head">
<th></th> <th v-for="column in activeColumns" :key="column.id">
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span></span></th> <template v-if="column.searchable">
<th><input v-model="officerFilter" aria-label="관직 필터" /><span></span></th> <input
<th><input aria-label="계급 필터" /><span></span></th> v-model="filters[column.id]"
<th><input aria-label="명성 필터" /><span></span></th> type="search"
<th><input aria-label="통솔 필터" /><span></span></th> :inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
<th><input aria-label="무력 필터" /><span></span></th> :aria-label="`${column.label} 필터`"
<th><input aria-label="지력 필터" /><span></span></th> :placeholder="column.searchable === 'number' ? '=, >, <' : ''"
<th><input aria-label=" 필터" /><span></span></th> />
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span></span></th> <span></span>
<th v-if="!isNarrow"></th> </template>
<th v-if="!isNarrow"></th> </th>
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span></span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(general, index) in generals" :key="general.id"> <tr v-for="(general, index) in generals" :key="general.id" :data-general-id="general.id">
<td class="icon-cell">
<img v-if="index < renderedIconCount" :src="iconUrl(general)" alt="" />
<span
v-else
class="icon-background"
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
></span>
</td>
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>{{ general.dedicationText }}<br />({{ general.bill.toLocaleString() }})</td>
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold.toLocaleString() }} </td>
<td v-if="!isNarrow">{{ general.rice.toLocaleString() }} </td>
<td v-if="!isNarrow" :title="general.personality?.info ?? ''">
{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}
</td>
<td <td
v-if="!isNarrow" v-for="column in activeColumns"
:title=" :key="column.id"
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n') :class="{
" 'icon-cell': column.id === 'icon',
'name-cell': column.id === 'name',
[`npc-${general.npcState}`]: column.id === 'name',
'numeric-cell':
column.searchable === 'number' ||
['goldRice_1', 'killturnAndRefresh_1'].includes(column.id),
}"
:title="cellTitle(general, column.id)"
> >
{{ special(general) }} <template v-if="column.id === 'icon'">
</td> <img v-if="index < 16" :src="iconUrl(general)" alt="" />
<td v-if="!isNarrow"> <span
{{ general.refreshScoreTotal }}<br />({{ general.belong ? '자주' : '안함' }}) v-else
class="icon-background"
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
></span>
</template>
<template v-else-if="column.id === 'gold'">{{ general.gold.toLocaleString() }} </template>
<template v-else-if="column.id === 'rice'">{{ general.rice.toLocaleString() }} </template>
<template v-else-if="column.id === 'crew'">
{{ visibleCrew(general)?.toLocaleString() ?? '?'
}}<span v-if="visibleCrew(general) !== null"></span>
</template>
<template v-else-if="column.id === 'belong'">{{ general.belong }}</template>
<template
v-else-if="column.id === 'refreshScoreTotal' || column.id === 'killturnAndRefresh_1'"
>
{{ general.refreshScoreTotal.toLocaleString() }}
</template>
<template v-else>{{ cellValue(general, column.id) }}</template>
</td> </td>
</tr> </tr>
<tr v-if="!generals.length" class="empty-row">
<td :colspan="activeColumns.length">검색 결과가 없습니다.</td>
</tr>
</tbody> </tbody>
</table> </table>
<div class="ag-compat-controls" aria-hidden="true"> <div class="ag-compat-controls" aria-hidden="true">
<button <button v-for="index in 55" :key="`button-${index}`" type="button" tabindex="-1"></button>
v-for="index in compatButtonCount" <input v-for="index in 42" :key="`input-${index}`" tabindex="-1" />
:key="`button-${index}`"
type="button"
tabindex="-1"
></button>
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
</div> </div>
</div> </div>
</main> </main>
@@ -222,9 +653,8 @@ onMounted(load);
<style scoped> <style scoped>
.general-page { .general-page {
width: 100%; width: 1000px;
min-width: 500px; min-width: 1000px;
max-width: 1000px;
height: 100vh; height: 100vh;
margin: 0 auto; margin: 0 auto;
font: 14px/21px var(--sammo-font-sans); font: 14px/21px var(--sammo-font-sans);
@@ -242,7 +672,6 @@ onMounted(load);
justify-content: center; justify-content: center;
background-color: transparent; background-color: transparent;
background-image: var(--sammo-texture-walnut); background-image: var(--sammo-texture-walnut);
/* Ref's `.back_bar` has no bottom rule; the grid below draws its own. */
font-size: 14px; font-size: 14px;
} }
.top-bar strong { .top-bar strong {
@@ -262,20 +691,19 @@ onMounted(load);
.right-actions { .right-actions {
right: 0; right: 0;
} }
/* Ref Lumen primary: the bottom edge carries the pressed-state movement. */
.top-button { .top-button {
display: inline-flex; display: inline-flex;
width: 89px;
height: 32px; height: 32px;
align-items: center; align-items: center;
justify-content: center;
padding: 0;
border: 0; border: 0;
border-right: 1px solid #151515; border-right: 1px solid #151515;
border-radius: 3px; border-radius: 3px;
color: #fff; color: #fff;
width: 89px;
justify-content: center;
padding: 0;
font-weight: 700;
font-size: 14px; font-size: 14px;
font-weight: 700;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@@ -289,14 +717,11 @@ onMounted(load);
background: #375a7f; background: #375a7f;
border-bottom: 0 solid #325172; border-bottom: 0 solid #325172;
} }
/* Ref Lumen primary: a 3px bottom edge appears on hover and stays while open. */
.mode-button:hover, .mode-button:hover,
.mode-button[aria-expanded='true'], .mode-button[aria-expanded='true'],
.mode-button:active { .mode-button:active {
border-bottom-width: 3px; border-bottom-width: 3px;
} }
.mode-button, .mode-button,
.columns-button { .columns-button {
width: 90px; width: 90px;
@@ -312,16 +737,22 @@ onMounted(load);
} }
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
z-index: 5; z-index: 20;
top: 32px; top: 32px;
right: 0; right: 0;
width: 150px; width: 170px;
max-height: calc(100vh - 40px);
padding: 4px; padding: 4px;
background: #252a2c; overflow-y: auto;
border: 1px solid #596164; border: 1px solid #596164;
background: #252a2c;
}
.view-mode-list {
width: 180px;
} }
.dropdown-menu button, .dropdown-menu button,
.dropdown-menu label { .dropdown-menu label,
.column-group-label {
display: block; display: block;
width: 100%; width: 100%;
padding: 5px; padding: 5px;
@@ -330,6 +761,30 @@ onMounted(load);
background: transparent; background: transparent;
text-align: left; text-align: left;
} }
.dropdown-menu button:not(.saved-setting-delete):hover,
.dropdown-menu label:hover {
background: #3a4144;
}
.menu-divider {
display: block;
height: 1px;
margin: 4px 0;
background: #596164;
}
.saved-setting {
display: grid;
grid-template-columns: 1fr 48px;
}
.saved-setting-delete {
padding: 2px !important;
text-align: center !important;
}
.column-group-label {
color: #9ca6aa;
}
.child-column {
padding-left: 17px !important;
}
.grid-shell { .grid-shell {
width: 100%; width: 100%;
height: calc(100vh - 32px); height: calc(100vh - 32px);
@@ -340,23 +795,21 @@ onMounted(load);
cursor: default; cursor: default;
} }
table { table {
width: 1000px; border-collapse: separate;
min-width: 1000px;
border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
background: #293033; background: #293033;
color: #f5f5f5;
font-size: 14px; font-size: 14px;
line-height: normal; line-height: normal;
color: #f5f5f5;
cursor: default; cursor: default;
} }
th, th,
td { td {
padding: 0 4px;
overflow: hidden;
border-right: 1px solid #40484b; border-right: 1px solid #40484b;
border-bottom: 1px solid #4a5255; border-bottom: 1px solid #4a5255;
padding: 0 4px;
text-align: center; text-align: center;
overflow: hidden;
} }
th { th {
height: 32px; height: 32px;
@@ -369,6 +822,36 @@ th {
height: 32px; height: 32px;
border-bottom-color: #303537; border-bottom-color: #303537;
} }
.group-toggle,
.sort-button {
width: 100%;
height: 100%;
padding: 0;
border: 0;
color: inherit;
background: transparent;
font: inherit;
}
.group-toggle,
.sort-button.sortable {
cursor: pointer;
}
.group-toggle:hover,
.sort-button.sortable:hover,
.group-toggle:focus-visible,
.sort-button.sortable:focus-visible {
color: #fff;
background: #303638;
outline: 1px solid #8aa4b2;
outline-offset: -2px;
}
.sort-button:disabled {
opacity: 1;
}
.sort-indicator {
color: #8dd4ff;
font-size: 10px;
}
.filter-head th { .filter-head th {
height: 32px; height: 32px;
padding: 3px 4px; padding: 3px 4px;
@@ -380,6 +863,14 @@ th {
background: #252a2c; background: #252a2c;
color: #fff; color: #fff;
} }
.filter-head input:focus-visible {
border-color: #8dd4ff;
outline: 1px solid #8dd4ff;
}
.filter-head input::placeholder {
color: #8f999d;
font-size: 10px;
}
.filter-head span { .filter-head span {
margin-left: 4px; margin-left: 4px;
color: #a5b5bf; color: #a5b5bf;
@@ -392,7 +883,7 @@ tbody tr:hover {
background: #343c3f; background: #343c3f;
} }
td { td {
white-space: nowrap; white-space: pre-line;
} }
.icon-cell { .icon-cell {
padding: 0 4px; padding: 0 4px;
@@ -416,21 +907,16 @@ td {
display: none; display: none;
} }
.name-cell { .name-cell {
text-align: left;
color: skyblue; color: skyblue;
text-align: left;
} }
th:nth-child(9), .numeric-cell {
td:nth-child(9),
th:nth-child(10),
td:nth-child(10) {
text-align: right; text-align: right;
} }
.state { .state {
margin: 40px; margin: 40px;
} }
.npc-0 { .npc-0,
color: skyblue;
}
.npc-1 { .npc-1 {
color: skyblue; color: skyblue;
} }
@@ -443,6 +929,10 @@ td:nth-child(10) {
.error { .error {
color: #ff7373; color: #ff7373;
} }
.empty-row td {
height: 68px;
text-align: center;
}
@media (max-width: 1000px) { @media (max-width: 1000px) {
.general-page { .general-page {
margin: 0; margin: 0;
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>; type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
@@ -147,7 +148,7 @@ onMounted(load);
> >
</td> </td>
<td>{{ general.killTurn }}</td> <td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(14, 19) }}</td> <td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -159,8 +160,8 @@ onMounted(load);
</tr> </tr>
<tr> <tr>
<td class="legacy-banner"> <td class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
HideD(hided62@gmail.com) / /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a> <a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td> </td>
</tr> </tr>
+3 -9
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -67,16 +68,9 @@ const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filte
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1); const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
const formatStartDate = (value: string): string => value.slice(0, 10); const formatStartDate = (value: string): string => formatServerDateTime(value, { format: 'date' });
const formatCommentDate = (value: string): string => { const formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const pad = (part: number) => String(part).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const voteColor = (index: number): string => const voteColor = (index: number): string =>
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!; ['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
+193 -56
View File
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { resolveTournamentStageName } from '../utils/tournamentStatus';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>; type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -12,22 +15,11 @@ const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const actionMessage = ref<string | null>(null); const actionMessage = ref<string | null>(null);
const adminEnabled = ref(false); const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
const typeNames = ['전력전', '통솔전', '일기토', '설전']; const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [ const typeStatNames = ['종합', '통솔', '무력', '지력'];
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value)); const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => { const load = async () => {
@@ -62,7 +54,9 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex); .sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-'); const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
const totalBet = computed(() => betting.value?.totalAmount ?? 0); const totalBet = computed(() => betting.value?.totalAmount ?? 0);
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--'); const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined); const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
const isParticipant = computed(() => const isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value) (snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
@@ -74,6 +68,26 @@ const groups = computed(() =>
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99)) .sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
) )
); );
const preliminaryGroups = computed(() =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.participants ?? [])
.filter((participant) => participant.groupId === index)
.sort((a, b) => (a.seedRank ?? 99) - (b.seedRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
)
);
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
const statOf = (participant: Snapshot['participants'][number] | undefined): number | '' => {
if (!participant) return '';
const type = snapshot.value?.state?.type ?? 0;
if (type === 0) return participant.leadership + participant.strength + participant.intel;
if (type === 1) return participant.leadership;
if (type === 2) return participant.strength;
return participant.intel;
};
const gamesOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : '';
const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : '';
const currentMatch = computed(() => { const currentMatch = computed(() => {
const state = snapshot.value?.state; const state = snapshot.value?.state;
if (!state || state.stage < 7 || state.stage > 10) return null; if (!state || state.stage < 7 || state.stage > 10) return null;
@@ -152,7 +166,7 @@ const start = async () => {
<section class="operator-row bg0">운영자 메세지 : <span></span></section> <section class="operator-row bg0">운영자 메세지 : <span></span></section>
<section class="state-row bg0"> <section class="state-row bg0">
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span> <span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }}, 개막시간 {{ openingTime }}, 경기당 ({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }}) {{ snapshot?.state?.termSeconds ?? '-' }})
</section> </section>
<section class="section-title bg2">16 승자전</section> <section class="section-title bg2">16 승자전</section>
@@ -164,7 +178,6 @@ const start = async () => {
:winner-id="snapshot?.state?.winnerId" :winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals" :bet-totals="betTotals"
:total-bet="totalBet" :total-bet="totalBet"
force-desktop
/> />
<section v-if="currentMatch" class="fight bg0"> <section v-if="currentMatch" class="fight bg0">
@@ -173,18 +186,35 @@ const start = async () => {
</section> </section>
<section class="section-title groups-title bg2">조별 본선 순위</section> <section class="section-title groups-title bg2">조별 본선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="본선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`final-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activeFinalGroup === groupIndex"
:class="{ active: activeFinalGroup === groupIndex }"
@click="activeFinalGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid bg0"> <section class="group-grid bg0">
<table v-for="(group, groupIndex) in groups" :key="groupIndex"> <table
v-for="(group, groupIndex) in groups"
:key="groupIndex"
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
>
<caption> <caption>
{{ {{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex] groupNames[groupIndex]
}} }}
</caption> </caption>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>장수</th> <th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th> <th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
@@ -196,26 +226,21 @@ const start = async () => {
<tbody> <tbody>
<tr v-for="rowIndex in 4" :key="rowIndex"> <tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td> <td>{{ rowIndex }}</td>
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td> <td class="general-cell">
<td> <GeneralIdentity
{{ v-if="group[rowIndex - 1]"
group[rowIndex - 1] :name="group[rowIndex - 1]!.name"
? (group[rowIndex - 1]!.win ?? 0) + :picture="group[rowIndex - 1]!.picture"
(group[rowIndex - 1]!.draw ?? 0) + :image-server="group[rowIndex - 1]!.imageServer"
(group[rowIndex - 1]!.lose ?? 0) :icon-size="24"
: '' />
}}
</td> </td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td> <td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td> <td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td> <td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td> <td>{{ pointsOf(group[rowIndex - 1]) }}</td>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
: ''
}}
</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td> <td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr> </tr>
</tbody> </tbody>
@@ -223,18 +248,35 @@ const start = async () => {
</section> </section>
<section class="section-title groups-title bg2">조별 예선 순위</section> <section class="section-title groups-title bg2">조별 예선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="예선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`preliminary-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activePreliminaryGroup === groupIndex"
:class="{ active: activePreliminaryGroup === groupIndex }"
@click="activePreliminaryGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid preliminary-grid bg0"> <section class="group-grid preliminary-grid bg0">
<table v-for="groupIndex in 8" :key="`preliminary-${groupIndex}`"> <table
v-for="(group, groupIndex) in preliminaryGroups"
:key="`preliminary-${groupIndex}`"
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
>
<caption> <caption>
{{ {{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1] groupNames[groupIndex]
}} }}
</caption> </caption>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>장수</th> <th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th> <th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
@@ -246,14 +288,22 @@ const start = async () => {
<tbody> <tbody>
<tr v-for="rowIndex in 8" :key="rowIndex"> <tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td> <td>{{ rowIndex }}</td>
<td></td> <td class="general-cell">
<td></td> <GeneralIdentity
<td></td> v-if="group[rowIndex - 1]"
<td></td> :name="group[rowIndex - 1]!.name"
<td></td> :picture="group[rowIndex - 1]!.picture"
<td></td> :image-server="group[rowIndex - 1]!.imageServer"
<td></td> :icon-size="24"
<td></td> />
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -287,8 +337,7 @@ const start = async () => {
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
HideD(hided62@gmail.com) / Credit
</small> </small>
</footer> </footer>
@@ -302,9 +351,10 @@ const start = async () => {
<style scoped> <style scoped>
.legacy-page { .legacy-page {
width: 2009px; width: 100%;
height: 1059px; max-width: 1200px;
overflow: hidden; min-width: 0;
min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font-family: var(--sammo-font-sans); font-family: var(--sammo-font-sans);
@@ -431,13 +481,15 @@ button:focus-visible {
} }
.group-grid { .group-grid {
display: grid; display: grid;
grid-template-columns: repeat(8, 250px); grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start; align-items: start;
gap: 8px;
padding: 8px;
} }
table { table {
width: 250px; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: auto; table-layout: fixed;
} }
caption { caption {
padding: 3px; padding: 3px;
@@ -450,14 +502,99 @@ th {
} }
th, th,
td { td {
height: 17px; height: 30px;
border: 1px solid #555; border: 1px solid #555;
padding: 1px 3px; padding: 1px 3px;
} }
.group-grid th:first-child,
.group-grid td:first-child {
width: 24px;
}
.group-grid th:nth-child(2),
.group-grid td:nth-child(2) {
width: 92px;
}
.general-cell {
overflow: hidden;
}
.group-tabs {
display: none;
}
.admin-row { .admin-row {
text-align: left; text-align: left;
} }
.error-row { .error-row {
color: #ff8080; color: #ff8080;
} }
@media (max-width: 800px) {
.legacy-page {
max-width: 100%;
font-size: 13px;
}
.legacy-title {
height: auto;
min-height: 55px;
}
.state-row {
font-size: 18px;
}
.section-title {
font-size: 20px;
}
.group-tabs {
display: grid;
grid-template-columns: repeat(8, minmax(44px, 1fr));
overflow-x: auto;
padding: 6px;
gap: 4px;
}
.group-tabs button {
min-width: 44px;
height: 34px;
margin: 0;
border-radius: 3px;
}
.group-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
color: #fff;
}
.group-grid {
display: block;
overflow-x: auto;
padding: 6px 0;
}
.group-grid table {
display: none;
min-width: 370px;
}
.group-grid table.mobile-active {
display: table;
}
.group-grid th,
.group-grid td {
height: 31px;
padding: 1px;
font-size: 11px;
}
.group-grid th:first-child,
.group-grid td:first-child {
width: 22px;
}
.group-grid th:nth-child(2),
.group-grid td:nth-child(2) {
width: 108px;
}
.tournament-guide {
padding: 10px;
font-size: 11px;
line-height: 16px;
}
.tournament-footer {
padding: 10px 0 0;
}
.tournament-footer small {
white-space: normal;
}
}
</style> </style>
+4 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
})) }))
); );
const timeLabel = (value: string): string => { const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => { const trafficColor = (percentage: number): string => {
const channel = (value: number): string => const channel = (value: number): string =>
@@ -204,8 +202,8 @@ onMounted(() => {
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
HideD(hided62@gmail.com) / /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a> <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td> </td>
</tr> </tr>
+2 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {}); const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
const formatTurn = (turnTime: string | null): string => { const formatTurn = (turnTime: string | null): string => {
if (!turnTime) { return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
return '--:--';
}
return turnTime.slice(14, 19);
}; };
onMounted(() => { onMounted(() => {
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
commandArgumentPresentation,
presentedCommandKeys,
} from '../src/components/command/commandArgumentPresentation.ts';
const cityCommands = [
'che_강행',
'che_이동',
'che_출병',
'che_첩보',
'che_화계',
'che_탈취',
'che_파괴',
'che_선동',
'che_수몰',
'che_백성동원',
'che_천도',
'che_허보',
'che_초토화',
'cr_인구이동',
'che_발령',
];
const nationCommands = [
'che_선전포고',
'che_급습',
'che_불가침파기제의',
'che_이호경식',
'che_종전제의',
'che_불가침제의',
'che_피장파장',
'che_물자원조',
];
const otherArgumentCommands = [
'che_증여',
'che_헌납',
'che_군량매매',
'che_몰수',
'che_포상',
'che_부대탈퇴지시',
'che_등용',
'che_선양',
'che_임관',
'che_장수대상임관',
'che_숙련전환',
'che_장비매매',
'che_건국',
'che_무작위건국',
'cr_건국',
'che_국기변경',
'che_국호변경',
'che_등용수락',
'che_NPC능동',
];
void test('provides Ref-level guidance for every in-scope argument command', () => {
const expected = [...cityCommands, ...nationCommands, ...otherArgumentCommands].sort();
assert.deepEqual(presentedCommandKeys().sort(), expected);
for (const commandKey of expected) {
assert.ok(commandArgumentPresentation(commandKey).lines.join(' ').length >= 12, commandKey);
}
assert.ok(!presentedCommandKeys().includes('che_징병'));
assert.ok(!presentedCommandKeys().includes('che_모병'));
assert.deepEqual(commandArgumentPresentation('che_징병'), { lines: [] });
assert.deepEqual(commandArgumentPresentation('che_모병'), { lines: [] });
});
void test('marks the same city and nation target families that Ref renders with a map', () => {
for (const commandKey of cityCommands) {
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'city', commandKey);
}
for (const commandKey of nationCommands) {
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'nation', commandKey);
}
});
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
DISPLAY_SETTINGS_VERSION,
compareGridValues,
defaultNationGeneralDisplaySettings,
matchesKoreanSearch,
matchesNumberSearch,
parseStoredDisplaySettings,
parseStoredSettingKey,
serializeDisplaySettings,
} from '../src/utils/nationGeneralGrid.ts';
void describe('nation general Ref-compatible grid state', () => {
void it('keeps the Ref default group and sort state', () => {
const normal = defaultNationGeneralDisplaySettings.normal;
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'stat')?.open, true);
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'specials')?.open, false);
assert.deepEqual(
normal.column.filter((entry) => entry.sort).map((entry) => [entry.colId, entry.sort, entry.sortIndex]),
[['refreshScoreTotal', 'desc', 0]]
);
const war = defaultNationGeneralDisplaySettings.war;
assert.equal(war.columnGroup.find((entry) => entry.groupId === 'stat')?.open, false);
assert.equal(war.column.find((entry) => entry.colId === 'stat_1')?.hide, false);
assert.equal(war.column.find((entry) => entry.colId === 'leadership')?.hide, false);
});
void it('round-trips named settings and rejects invalid versions', () => {
const settings = new Map([['전투 보기', defaultNationGeneralDisplaySettings.war]]);
const restored = parseStoredDisplaySettings(serializeDisplaySettings(settings));
assert.equal(restored.get('전투 보기')?.column.find((entry) => entry.colId === 'icon')?.hide, true);
assert.deepEqual(
parseStoredDisplaySettings(JSON.stringify({ version: DISPLAY_SETTINGS_VERSION + 1, settings: [] })),
new Map()
);
assert.deepEqual(parseStoredDisplaySettings('{broken'), new Map());
});
void it('accepts only valid last-used setting tuples', () => {
assert.deepEqual(parseStoredSettingKey('[true,"normal"]'), [true, 'normal']);
assert.deepEqual(parseStoredSettingKey('[false,"내 설정"]'), [false, '내 설정']);
assert.equal(parseStoredSettingKey('[true,"missing"]'), null);
});
void it('matches Korean names by text and initial consonants', () => {
assert.equal(matchesKoreanSearch('테스트장수', '테스트'), true);
assert.equal(matchesKoreanSearch('테스트장수', 'ㅌㅅㅌㅈㅅ'), true);
assert.equal(matchesKoreanSearch('테스트장수', 'ㄱㄴ'), false);
});
void it('uses Ref-like numeric comparisons and stable Korean text ordering', () => {
assert.equal(matchesNumberSearch(90, '90'), true);
assert.equal(matchesNumberSearch(90, '>= 80'), true);
assert.equal(matchesNumberSearch(90, '< 80'), false);
assert.equal(compareGridValues(10, 2) > 0, true);
assert.equal(compareGridValues(null, 2) > 0, true);
assert.equal(compareGridValues('가', '나') < 0, true);
});
});
@@ -3,7 +3,12 @@ import { describe, it } from 'node:test';
import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts'; import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts';
const participants = Array.from({ length: 16 }, (_, index) => ({ id: index + 1, name: `장수${index + 1}` })); const participants = Array.from({ length: 16 }, (_, index) => ({
id: index + 1,
name: `장수${index + 1}`,
picture: `${index + 1}.jpg`,
imageServer: index % 2,
}));
const matches = [ const matches = [
...Array.from({ length: 8 }, (_, index) => ({ ...Array.from({ length: 8 }, (_, index) => ({
id: index + 1, id: index + 1,
@@ -37,6 +42,8 @@ void describe('tournament bracket', () => {
const bracket = buildTournamentBracket(participants, matches, 1); const bracket = buildTournamentBracket(participants, matches, 1);
assert.equal(bracket.champion.name, '장수1'); assert.equal(bracket.champion.name, '장수1');
assert.equal(bracket.champion.picture, '1.jpg');
assert.equal(bracket.top16.slots[1]?.imageServer, 1);
assert.deepEqual( assert.deepEqual(
bracket.top16.slots.map((slot) => slot.name), bracket.top16.slots.map((slot) => slot.name),
participants.map((participant) => participant.name) participants.map((participant) => participant.name)
@@ -52,10 +59,16 @@ void describe('tournament bracket', () => {
}); });
void it('renders missing future rounds as stable empty slots without inventing generals', () => { void it('renders missing future rounds as stable empty slots without inventing generals', () => {
const bracket = buildTournamentBracket(participants, matches.filter((match) => match.stage === 7)); const bracket = buildTournamentBracket(
participants,
matches.filter((match) => match.stage === 7)
);
assert.equal(bracket.champion.name, '-'); assert.equal(bracket.champion.name, '-');
assert.deepEqual(bracket.final.slots.map((slot) => slot.name), ['-', '-']); assert.deepEqual(
bracket.final.slots.map((slot) => slot.name),
['-', '-']
);
assert.equal(bracket.top16.slots[0]?.name, '장수1'); assert.equal(bracket.top16.slots[0]?.name, '장수1');
assert.equal(bracket.top16.slots[15]?.name, '장수16'); assert.equal(bracket.top16.slots[15]?.name, '장수16');
}); });
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
void describe('tournament status labels', () => {
void it('describes inactive and active tournament stages', () => {
assert.equal(resolveTournamentStageName(0), '경기 없음');
assert.equal(resolveTournamentStageName(1), '참가 모집중');
assert.equal(resolveTournamentStageName(6), '베팅 진행중');
assert.equal(resolveTournamentStageName(10), '결승 진행중');
});
void it('uses a safe fallback for an unknown stage', () => {
assert.equal(resolveTournamentStageName(11), '상태 확인 중');
assert.equal(resolveTournamentStageName(-1), '상태 확인 중');
});
});
+17 -4
View File
@@ -1280,7 +1280,10 @@ export const adminRouter = router({
sourceRef = resolved; sourceRef = resolved;
} }
const scenarios = await listScenarioPreviews({ gitRef: resolved }); const scenarios = await listScenarioPreviews({ gitRef: resolved });
if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) { if (
profile.currentScenario === null ||
!scenarios.some((scenario) => String(scenario.id) === profile.currentScenario)
) {
throw new Error('Current scenario is not available at source.'); throw new Error('Current scenario is not available at source.');
} }
} catch { } catch {
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
.map((profile) => ({ .map((profile) => ({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile, profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
meta: { meta: {
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}), ...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}),
}, },
@@ -1661,7 +1666,8 @@ export const adminRouter = router({
); );
const profile = await ctx.profiles.getProfile(input.profileName); const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
const parsedScenarioId = Number(profile.scenario); const parsedScenarioId =
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null; currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
gitRef = profile.buildCommitSha?.trim(); gitRef = profile.buildCommitSha?.trim();
if (!gitRef) { if (!gitRef) {
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
upsert: profileAdminProcedure upsert: profileAdminProcedure
.input( .input(
z.object({ z.object({
profile: z.string().min(1).max(32), profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
scenario: z.string().min(1).max(64), instanceKey: z
.string()
.regex(/^[a-z0-9-]{1,64}$/)
.optional(),
currentScenario: z.string().min(1).max(64).nullable().optional(),
scenario: z.string().min(1).max(64).optional(),
apiPort: z.number().int().min(1).max(65535), apiPort: z.number().int().min(1).max(65535),
status: zProfileStatus.optional(), status: zProfileStatus.optional(),
preopenAt: z.string().datetime().optional(), preopenAt: z.string().datetime().optional(),
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
const status = input.status ?? 'STOPPED'; const status = input.status ?? 'STOPPED';
return ctx.profiles.upsertProfile({ return ctx.profiles.upsertProfile({
profile: input.profile, profile: input.profile,
instanceKey: input.instanceKey,
currentScenario: input.currentScenario,
scenario: input.scenario, scenario: input.scenario,
apiPort: input.apiPort, apiPort: input.apiPort,
status, status,
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
export type LobbyProfileStatus = { export type LobbyProfileStatus = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
status: GatewayProfileStatus; status: GatewayProfileStatus;
apiPort: number; apiPort: number;
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
return { return {
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario, scenario: row.scenario,
status: row.status, status: row.status,
apiPort: row.apiPort, apiPort: row.apiPort,
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
...baseEnv, ...baseEnv,
GAME_API_ROLE: 'server', GAME_API_ROLE: 'server',
PROFILE: profile.profile, PROFILE: profile.profile,
SCENARIO: profile.scenario, SCENARIO: profile.currentScenario ?? 'default',
GAME_PROFILE_NAME: profile.profileName, GAME_PROFILE_NAME: profile.profileName,
GAME_API_PORT: String(profile.apiPort), GAME_API_PORT: String(profile.apiPort),
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`, GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
GAME_ENGINE_ROLE: 'turn-daemon', GAME_ENGINE_ROLE: 'turn-daemon',
TURN_PROFILE: profile.profile, TURN_PROFILE: profile.profile,
PROFILE: profile.profile, PROFILE: profile.profile,
SCENARIO: profile.scenario, SCENARIO: profile.currentScenario ?? 'default',
TURN_PROFILE_NAME: profile.profileName, TURN_PROFILE_NAME: profile.profileName,
}; };
return { return {
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} = parseInstallOptions(action); } = parseInstallOptions(action);
const tickOverride = const tickOverride =
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined; installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario); const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
if (!scenarioId) { if (scenarioId === null) {
return { status: 'FAILED', detail: 'scenarioId is missing' }; return { status: 'FAILED', detail: 'scenarioId is missing' };
} }
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile); const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING'; const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const publishedProfile = await updateClaimedProfile( const publishedProfile = await updateClaimedProfile(
{ {
scenario: String(scenarioId), currentScenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
completedAt, completedAt,
error: null, error: null,
}); });
if (String(scenarioId) !== profile.scenario) { if (String(scenarioId) !== profile.currentScenario) {
await this.repository.updateScenario(profile.profileName, String(scenarioId)); await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
} }
return this.repository.updateStatus(profile.profileName, desiredStatus, { return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
releasePrepared = true; releasePrepared = true;
const builtProfile = publishedProfile ?? { const builtProfile = publishedProfile ?? {
...profile, ...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId), scenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
meta: Record<string, unknown>; meta: Record<string, unknown>;
}> { }> {
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile); const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile);
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario); let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.currentScenario);
let tickSeconds: number | undefined = overrides?.tickSeconds; let tickSeconds: number | undefined = overrides?.tickSeconds;
let meta: Record<string, unknown> = {}; let meta: Record<string, unknown> = {};
const connector = createGamePostgresConnector({ url: databaseUrl }); const connector = createGamePostgresConnector({ url: databaseUrl });
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
export interface GatewayProfileRecord { export interface GatewayProfileRecord {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
apiPort: number; apiPort: number;
status: GatewayProfileStatus; status: GatewayProfileStatus;
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
export interface GatewayProfileUpsertInput { export interface GatewayProfileUpsertInput {
profile: string; profile: string;
scenario: string; instanceKey?: string;
currentScenario?: string | null;
/** @deprecated Accepted while older bootstrap clients are still supported. */
scenario?: string;
apiPort: number; apiPort: number;
status?: GatewayProfileStatus; status?: GatewayProfileStatus;
preopenAt?: string; preopenAt?: string;
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
} }
export interface GatewayClaimedProfileUpdate { export interface GatewayClaimedProfileUpdate {
scenario?: string; currentScenario?: string | null;
status?: GatewayProfileStatus; status?: GatewayProfileStatus;
buildStatus?: GatewayBuildStatus; buildStatus?: GatewayBuildStatus;
buildCommitSha?: string | null; buildCommitSha?: string | null;
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
listProfiles(): Promise<GatewayProfileRecord[]>; listProfiles(): Promise<GatewayProfileRecord[]>;
getProfile(profileName: string): Promise<GatewayProfileRecord | null>; getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>; upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>; updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
updateStatus( updateStatus(
profileName: string, profileName: string,
status: GatewayProfileStatus, status: GatewayProfileStatus,
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
type GatewayProfileRow = { type GatewayProfileRow = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
scenario: string; scenario: string;
apiPort: number; apiPort: number;
status: GatewayProfileStatus; status: GatewayProfileStatus;
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario, scenario: row.scenario,
apiPort: row.apiPort, apiPort: row.apiPort,
status: row.status, status: row.status,
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}); });
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`; export const buildGatewayProfileName = (profile: string, instanceKey: string): string => `${profile}:${instanceKey}`;
export const resolveGatewayProfileIdentity = (
input: GatewayProfileUpsertInput
): {
instanceKey: string;
currentScenario: string | null;
shouldUpdateCurrentScenario: boolean;
} => {
const instanceKey = input.instanceKey ?? input.scenario ?? 'default';
if (input.currentScenario !== undefined) {
return { instanceKey, currentScenario: input.currentScenario, shouldUpdateCurrentScenario: true };
}
if (input.instanceKey === undefined && input.scenario !== undefined && input.scenario !== 'default') {
return { instanceKey, currentScenario: input.scenario, shouldUpdateCurrentScenario: true };
}
return { instanceKey, currentScenario: null, shouldUpdateCurrentScenario: false };
};
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({ const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
id: row.id, id: row.id,
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> { async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({ const rows = await prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }], orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
}); });
return rows.map(mapProfile); return rows.map(mapProfile);
}, },
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
}, },
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> { async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
const profileName = buildProfileName(input.profile, input.scenario); const { instanceKey, currentScenario, shouldUpdateCurrentScenario } = resolveGatewayProfileIdentity(input);
const profileName = buildGatewayProfileName(input.profile, instanceKey);
const row = await prisma.gatewayProfile.upsert({ const row = await prisma.gatewayProfile.upsert({
where: { profileName }, where: { profileName },
create: { create: {
profileName, profileName,
profile: input.profile, profile: input.profile,
scenario: input.scenario, instanceKey,
currentScenario,
scenario: currentScenario ?? 'default',
apiPort: input.apiPort, apiPort: input.apiPort,
status: input.status ?? 'STOPPED', status: input.status ?? 'STOPPED',
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null, preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject, meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
}, },
update: { update: {
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
apiPort: input.apiPort, apiPort: input.apiPort,
status: input.status, status: input.status,
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined, preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined,
@@ -373,11 +405,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
}); });
return mapProfile(row); return mapProfile(row);
}, },
async updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null> { async updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.update({ const row = await prisma.gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
scenario, currentScenario: scenario,
scenario: scenario ?? 'default',
}, },
}); });
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return tx.gatewayProfile.update({ return tx.gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
scenario: patch.scenario, currentScenario: patch.currentScenario,
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
status: patch.status, status: patch.status,
buildStatus: patch.buildStatus, buildStatus: patch.buildStatus,
buildCommitSha: patch.buildCommitSha, buildCommitSha: patch.buildCommitSha,
+4 -4
View File
@@ -3,8 +3,8 @@ export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya',
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index])); const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
export const compareGatewayProfiles = ( export const compareGatewayProfiles = (
left: { profile: string; scenario: string }, left: { profile: string; instanceKey: string },
right: { profile: string; scenario: string } right: { profile: string; instanceKey: string }
): number => { ): number => {
const unknownRank = GATEWAY_PROFILE_ORDER.length; const unknownRank = GATEWAY_PROFILE_ORDER.length;
const profileOrder = const profileOrder =
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
const profileNameOrder = left.profile.localeCompare(right.profile); const profileNameOrder = left.profile.localeCompare(right.profile);
if (profileNameOrder !== 0) return profileNameOrder; if (profileNameOrder !== 0) return profileNameOrder;
return left.scenario.localeCompare(right.scenario); return left.instanceKey.localeCompare(right.instanceKey);
}; };
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] => export const orderGatewayProfiles = <T extends { profile: string; instanceKey: string }>(profiles: readonly T[]): T[] =>
[...profiles].sort(compareGatewayProfiles); [...profiles].sort(compareGatewayProfiles);
+5 -1
View File
@@ -85,6 +85,8 @@ const buildCaller = async (
const profile = { const profile = {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: options.profileScenario ?? '2',
scenario: options.profileScenario ?? '2', scenario: options.profileScenario ?? '2',
apiPort: 15003, apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const), status: options.initialProfileStatus ?? ('STOPPED' as const),
@@ -98,7 +100,7 @@ const buildCaller = async (
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => { updateStatus: async (_profileName, status) => {
updatedStatuses.push(status); updatedStatuses.push(status);
return { ...profile, status }; return { ...profile, status };
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
{ {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
meta: {}, meta: {},
}, },
]); ]);
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
const profile = { const profile = {
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null), getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async () => profile, updateStatus: async () => profile,
updateBuildStatus: async () => profile, updateBuildStatus: async () => profile,
updateMeta: async () => profile, updateMeta: async () => profile,
+7 -1
View File
@@ -92,6 +92,8 @@ const buildCaller = (
{ {
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -103,6 +105,8 @@ const buildCaller = (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15015, apiPort: 15015,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -119,7 +123,7 @@ const buildCaller = (
upsertProfile: async () => { upsertProfile: async () => {
throw new Error('not used'); throw new Error('not used');
}, },
updateScenario: async () => null, updateCurrentScenario: async () => null,
updateStatus: async () => null, updateStatus: async () => null,
updateBuildStatus: async () => null, updateBuildStatus: async () => null,
updateMeta: async () => null, updateMeta: async () => null,
@@ -167,6 +171,8 @@ const buildCaller = (
profileRows.map((profile) => ({ profileRows.map((profile) => ({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile, profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
scenario: profile.scenario, scenario: profile.scenario,
status: profile.status, status: profile.status,
apiPort: profile.apiPort, apiPort: profile.apiPort,
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const profile: GatewayProfileRecord = { const profile: GatewayProfileRecord = {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: 'STOPPED', status: 'STOPPED',
@@ -58,7 +60,7 @@ const createHarness = (
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => { updateStatus: async (_profileName, status) => {
statuses.push(status); statuses.push(status);
return { ...profile, status }; return { ...profile, status };
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({ const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING', status: 'RUNNING',
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
}); });
it('keeps the instance identity stable while passing the mutable current scenario', () => {
const definitions = buildProcessDefinitions(
{
...buildProfile(),
profileName: 'che:default',
instanceKey: 'default',
currentScenario: '1010',
scenario: '1010',
},
processConfig
);
expect(definitions.api.name).toBe('sammo:che:default:game-api');
expect(definitions.api.env).toMatchObject({
GAME_PROFILE_NAME: 'che:default',
SCENARIO: '1010',
});
expect(definitions.daemon.env).toMatchObject({
TURN_PROFILE_NAME: 'che:default',
SCENARIO: '1010',
});
});
it('uses the legacy default scenario marker only for an uninitialized instance runtime', () => {
const definitions = buildProcessDefinitions(
{ ...buildProfile(), currentScenario: null, scenario: 'default' },
processConfig
);
expect(definitions.api.env.SCENARIO).toBe('default');
expect(definitions.daemon.env.SCENARIO).toBe('default');
});
it('does not forward PM2 identity or parent runtime roles to profile processes', () => { it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
const definitions = buildProcessDefinitions(buildProfile(), { const definitions = buildProcessDefinitions(buildProfile(), {
...processConfig, ...processConfig,
@@ -14,6 +14,8 @@ const makeProfile = (
): GatewayProfileRecord => ({ ): GatewayProfileRecord => ({
profileName, profileName,
profile: profileName.split(':')[0] ?? 'che', profile: profileName.split(':')[0] ?? 'che',
instanceKey: profileName.split(':')[1] ?? 'default',
currentScenario: null,
scenario: profileName.split(':')[1] ?? 'default', scenario: profileName.split(':')[1] ?? 'default',
apiPort: 15_003, apiPort: 15_003,
status: 'RUNNING', status: 'RUNNING',
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
const profile: GatewayProfileRecord = { const profile: GatewayProfileRecord = {
profileName: 'che:1010', profileName: 'che:1010',
profile: 'che', profile: 'che',
instanceKey: '1010',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING', status: 'RUNNING',
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async () => profile, updateStatus: async () => profile,
updateBuildStatus: async () => profile, updateBuildStatus: async () => profile,
updateMeta: async () => profile, updateMeta: async () => profile,
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import {
buildGatewayProfileName,
resolveGatewayProfileIdentity,
type GatewayProfileUpsertInput,
} from '../src/orchestrator/profileRepository.js';
const resolve = (input: Partial<GatewayProfileUpsertInput>) =>
resolveGatewayProfileIdentity({
profile: 'che',
apiPort: 15003,
...input,
});
describe('Gateway profile identity', () => {
it('builds the immutable technical id from profile and instance key', () => {
expect(buildGatewayProfileName('che', 'default')).toBe('che:default');
});
it('does not treat a new default instance as an initialized scenario', () => {
expect(resolve({ instanceKey: 'default' })).toEqual({
instanceKey: 'default',
currentScenario: null,
shouldUpdateCurrentScenario: false,
});
});
it('accepts the old bootstrap default marker without clearing an existing scenario on upsert', () => {
expect(resolve({ scenario: 'default' })).toEqual({
instanceKey: 'default',
currentScenario: null,
shouldUpdateCurrentScenario: false,
});
});
it('maps a legacy non-default scenario to both identity and current state', () => {
expect(resolve({ scenario: '2' })).toEqual({
instanceKey: '2',
currentScenario: '2',
shouldUpdateCurrentScenario: true,
});
});
it('keeps a default instance stable when its current scenario changes', () => {
expect(resolve({ instanceKey: 'default', currentScenario: '1010' })).toEqual({
instanceKey: 'default',
currentScenario: '1010',
shouldUpdateCurrentScenario: true,
});
});
});
+10 -10
View File
@@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => {
it('uses the public server order instead of alphabetical profile order', () => { it('uses the public server order instead of alphabetical profile order', () => {
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({ const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
profile, profile,
scenario: 'default', instanceKey: 'default',
})); }));
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER); expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
}); });
it('orders scenarios within a profile and places unknown profiles afterward', () => { it('orders instance keys within a profile and places unknown profiles afterward', () => {
const profiles = [ const profiles = [
{ profile: 'zeta', scenario: 'default' }, { profile: 'zeta', instanceKey: 'default' },
{ profile: 'che', scenario: '20' }, { profile: 'che', instanceKey: '20' },
{ profile: 'alpha', scenario: 'default' }, { profile: 'alpha', instanceKey: 'default' },
{ profile: 'che', scenario: '10' }, { profile: 'che', instanceKey: '10' },
]; ];
expect(orderGatewayProfiles(profiles)).toEqual([ expect(orderGatewayProfiles(profiles)).toEqual([
{ profile: 'che', scenario: '10' }, { profile: 'che', instanceKey: '10' },
{ profile: 'che', scenario: '20' }, { profile: 'che', instanceKey: '20' },
{ profile: 'alpha', scenario: 'default' }, { profile: 'alpha', instanceKey: 'default' },
{ profile: 'zeta', scenario: 'default' }, { profile: 'zeta', instanceKey: 'default' },
]); ]);
expect(profiles[0]?.profile).toBe('zeta'); expect(profiles[0]?.profile).toBe('zeta');
}); });
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs', gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
gameSchemaHead: '20260803000000_add_logical_game_clock', gameSchemaHead: '20260803000000_add_logical_game_clock',
}); });
}); });
@@ -150,6 +150,8 @@ const installFixture = async (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
meta: {}, meta: {},
}, },
]); ]);
@@ -160,6 +162,8 @@ const installFixture = async (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
apiPort: 15015, apiPort: 15015,
status: 'RUNNING', status: 'RUNNING',
@@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p
await expect(versionTab).toBeFocused(); await expect(versionTab).toBeFocused();
const tabAndHeaderGeometry = await Promise.all([ const tabAndHeaderGeometry = await Promise.all([
tabs.evaluate((element) => element.getBoundingClientRect().top), tabs.evaluate((element) => element.getBoundingClientRect().top),
page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top), page
.getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true })
.evaluate((element) => element.getBoundingClientRect().top),
]); ]);
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]); expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true });
@@ -41,6 +41,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
{ {
profileName: 'hwe:2', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
status: 'RUNNING', status: 'RUNNING',
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
@@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
{ {
profileName: 'hwe:2', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
meta: { korName: '환상서버' }, meta: { korName: '환상서버' },
}, },
] ]
@@ -209,7 +213,7 @@ test('scoped administrators see the same navigation while ordinary users do not'
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible(); await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click(); await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' }); const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible(); await expect(scopedNavigation.getByRole('link', { name: '환상서버 [2]' })).toBeVisible();
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0); await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0); await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
await scopedContext.close(); await scopedContext.close();
@@ -55,8 +55,10 @@ type FixtureState = {
}; };
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
profileName: 'che:2', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: runtimeRunning ? 'RUNNING' : 'STOPPED', status: runtimeRunning ? 'RUNNING' : 'STOPPED',
@@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
activeOperation: null, activeOperation: null,
runtimeActions: [], runtimeActions: [],
runtime: { runtime: {
profileName: 'che:2', profileName: 'che:default',
frontendRunning: runtimeRunning, frontendRunning: runtimeRunning,
apiRunning: runtimeRunning, apiRunning: runtimeRunning,
daemonRunning: runtimeRunning, daemonRunning: runtimeRunning,
@@ -145,10 +147,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
await route.abort('failed'); await route.abort('failed');
return; return;
} }
if ( if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) {
names.includes('admin.releases.gatewayState') &&
(state.gatewayStateFailuresRemaining ?? 0) > 0
) {
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1; state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1; state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
await route.fulfill({ await route.fulfill({
@@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.profiles.listNavigation') { if (name === 'admin.profiles.listNavigation') {
return response([ return response([
{ {
profileName: 'che:2', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: '2',
meta: { korName: '천하서버' }, meta: { korName: '천하서버' },
}, },
]); ]);
@@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestReset') { if (name === 'admin.operations.requestReset') {
const operation: Operation = { const operation: Operation = {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestDeploy') { if (name === 'admin.operations.requestDeploy') {
const operation: Operation = { const operation: Operation = {
id: '66666666-6666-4666-8666-666666666666', id: '66666666-6666-4666-8666-666666666666',
profileName: 'che:2', profileName: 'che:default',
type: 'DEPLOY', type: 'DEPLOY',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'BRANCH', sourceMode: 'BRANCH',
@@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
type === 'START' type === 'START'
? '22222222-2222-4222-8222-222222222222' ? '22222222-2222-4222-8222-222222222222'
: '33333333-3333-4333-8333-333333333333', : '33333333-3333-4333-8333-333333333333',
profileName: 'che:2', profileName: 'che:default',
type, type,
status: 'SUCCEEDED', status: 'SUCCEEDED',
payload: {}, payload: {},
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.retry') { if (name === 'admin.operations.retry') {
const operation: Operation = { const operation: Operation = {
id: '44444444-4444-4444-8444-444444444444', id: '44444444-4444-4444-8444-444444444444',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario'); await page.goto('admin/servers/che%3Adefault/scenario');
await expect(page.getByTestId('server-operations-page')).toBeVisible(); await expect(page.getByTestId('server-operations-page')).toBeVisible();
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/); await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/scenario$/);
await expect(page.getByTestId('source-current')).toBeChecked(); await expect(page.getByTestId('source-current')).toBeChecked();
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
await expect(page.getByTestId('scenario-select')).toHaveValue('2'); await expect(page.getByTestId('scenario-select')).toHaveValue('2');
@@ -490,13 +491,14 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click(); await page.getByTestId('load-scenarios').click();
await page.getByTestId('scenario-select').selectOption('5'); await page.getByTestId('scenario-select').selectOption('5');
await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30');
await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').hover();
await page.getByTestId('request-reset').click(); await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('RESET'); await expect(page.getByTestId('operations-table')).toContainText('RESET');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
@@ -544,6 +546,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"');
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -588,9 +591,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
mobileOperationTableGeometry.scrollerWidth mobileOperationTableGeometry.scrollerWidth
); );
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect( expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth mobileOperationTableGeometry.viewportWidth
).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth); );
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual( expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth mobileOperationTableGeometry.viewportWidth
); );
@@ -612,7 +615,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/version'); await page.goto('admin/servers/che%3Adefault/version');
await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute( await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute(
@@ -624,7 +627,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete'); await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
@@ -653,7 +656,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario'); await page.goto('admin/servers/che%3Adefault/scenario');
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20'); await expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
await page.getByText('고급 시나리오 옵션').click(); await page.getByText('고급 시나리오 옵션').click();
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타'); await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
@@ -677,7 +680,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2'); await page.goto('admin/servers/che%3Adefault');
await page.getByText('서버 리셋 기본 옵션').click(); await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10'); await page.getByTestId('meta-reset-turn-term').selectOption('10');
await page.getByTestId('meta-reset-npc-mode').selectOption('2'); await page.getByTestId('meta-reset-npc-mode').selectOption('2');
@@ -740,7 +743,7 @@ test('shows a dismissible error toast when profile metadata persistence fails',
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2'); await page.goto('admin/servers/che%3Adefault');
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error'); await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
await page.getByRole('button', { name: '메타 저장' }).click(); await page.getByRole('button', { name: '메타 저장' }).click();
@@ -763,7 +766,7 @@ test('renders the fixed-profile version form without waiting for the server list
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2/version'); await page.goto('admin/servers/che%3Adefault/version');
await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 }); await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
expect(state.profileNavigationResolved).toBe(false); expect(state.profileNavigationResolved).toBe(false);
await expect.poll(() => state.profileNavigationResolved).toBe(true); await expect.poll(() => state.profileNavigationResolved).toBe(true);
@@ -780,7 +783,7 @@ test('recovers the current-version scenario catalog after the initial request fa
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2/scenario'); await page.goto('admin/servers/che%3Adefault/scenario');
await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.'); await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.');
await expect(page.getByTestId('request-reset')).toBeDisabled(); await expect(page.getByTestId('request-reset')).toBeDisabled();
await page.getByTestId('load-scenarios').click(); await page.getByTestId('load-scenarios').click();
@@ -788,7 +791,9 @@ test('recovers the current-version scenario catalog after the initial request fa
await expect(page.getByTestId('request-reset')).toBeEnabled(); await expect(page.getByTestId('request-reset')).toBeEnabled();
}); });
test('renders the server navigation before the detailed runtime profile request resolves', async ({ page }) => { test('renders the stable server identity without exposing the default suffix as the display name', async ({
page,
}, testInfo) => {
const state: FixtureState = { const state: FixtureState = {
operations: [], operations: [],
gatewayOperations: [], gatewayOperations: [],
@@ -798,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2'); await page.goto('admin/servers/che%3Adefault');
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
await expect(navigation.getByRole('link', { name: '천하서버 (che:2)' })).toBeVisible({ timeout: 900 }); const profileLink = navigation.getByRole('link', { name: '천하서버' });
await expect(profileLink).toBeVisible({ timeout: 900 });
await expect(profileLink).toHaveAttribute('title', '서버 ID: che:default');
await expect(navigation).not.toContainText('천하서버 (che:default)');
await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 }); await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 });
await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toBeVisible();
await expect(page.getByText('현재 시나리오: 2')).toBeVisible();
await profileLink.focus();
const desktop = await profileLink.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
width: rect.width,
height: rect.height,
overflow: element.scrollWidth - element.clientWidth,
backgroundColor: style.backgroundColor,
color: style.color,
};
});
expect(desktop.width).toBeGreaterThan(100);
expect(desktop.height).toBeGreaterThan(30);
expect(desktop.overflow).toBeLessThanOrEqual(0);
expect(desktop.backgroundColor).toBe('rgb(45, 27, 8)');
expect(desktop.color).toBe('rgb(253, 230, 138)');
await page.screenshot({ path: testInfo.outputPath('profile-identity-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '관리자 메뉴' }).click();
await expect(profileLink).toBeVisible();
expect(
await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
).toBeLessThanOrEqual(0);
await page.screenshot({ path: testInfo.outputPath('profile-identity-mobile.png'), fullPage: true });
expect(state.profileNavigationRequests).toBe(1); expect(state.profileNavigationRequests).toBe(1);
}); });
@@ -811,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c
gatewayOperations: [], gatewayOperations: [],
runtimeRunning: true, runtimeRunning: true,
requestBodies: [], requestBodies: [],
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }], capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:default'] }],
}; };
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario'); await page.goto('admin/servers/che%3Adefault/scenario');
await expect(page.getByTestId('source-current')).toBeChecked(); await expect(page.getByTestId('source-current')).toBeChecked();
await expect(page.getByTestId('source-branch')).toHaveCount(0); await expect(page.getByTestId('source-branch')).toHaveCount(0);
await expect(page.getByTestId('source-commit')).toHaveCount(0); await expect(page.getByTestId('source-commit')).toHaveCount(0);
@@ -994,9 +1031,7 @@ test('moves long Gateway release errors out of the table column into an expandab
expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1); expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual( expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
mobileGeometry.viewportWidth
);
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true });
@@ -1041,7 +1076,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
operations: [ operations: [
{ {
id: '55555555-5555-4555-8555-555555555555', id: '55555555-5555-4555-8555-555555555555',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'FAILED', status: 'FAILED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -1062,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario'); await page.goto('admin/servers/che%3Adefault/scenario');
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
const failure = page.getByTestId('operations-table').getByText(longError); const failure = page.getByTestId('operations-table').getByText(longError);
@@ -16,12 +16,28 @@ const adminNavigationClient = directTrpc.admin as unknown as {
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } }; capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
profiles: { profiles: {
listNavigation: { listNavigation: {
query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>; query: () => Promise<
Array<{
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
}>
>;
}; };
}; };
}; };
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]); const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
const profiles = ref<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>([]); const profiles = ref<
Array<{
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
}>
>([]);
const isRootAdmin = computed(() => const isRootAdmin = computed(() =>
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser') (auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
@@ -38,7 +54,8 @@ const hasAnyProfileCapability = computed(() =>
const profileLabel = (profile: (typeof profiles.value)[number]): string => { const profileLabel = (profile: (typeof profiles.value)[number]): string => {
const korName = profile.meta?.korName; const korName = profile.meta?.korName;
return typeof korName === 'string' && korName.trim() ? `${korName} (${profile.profileName})` : profile.profileName; const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile;
return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`;
}; };
const navigation = computed(() => [ const navigation = computed(() => [
@@ -70,6 +87,7 @@ const navigation = computed(() => [
...profiles.value.map((profile) => ({ ...profiles.value.map((profile) => ({
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`, to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
label: profileLabel(profile), label: profileLabel(profile),
title: `서버 ID: ${profile.profileName}`,
icon: '└', icon: '└',
exact: false, exact: false,
visible: true, visible: true,
@@ -156,6 +174,7 @@ onMounted(async () => {
:class="{ child: item.child }" :class="{ child: item.child }"
:active-class="item.exact ? '' : 'active'" :active-class="item.exact ? '' : 'active'"
:exact-active-class="item.exact ? 'active' : ''" :exact-active-class="item.exact ? 'active' : ''"
:title="'title' in item ? item.title : undefined"
@click="menuOpen = false" @click="menuOpen = false"
> >
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span> <span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -130,7 +131,7 @@ const scheduleDeletion = async (): Promise<void> => {
currentCredential, currentCredential,
}); });
window.localStorage.removeItem('sammo-session-token'); window.localStorage.removeItem('sammo-session-token');
successMessage.value = `${new Date(result.deleteAfter).toLocaleDateString('ko-KR')}까지 정보가 보존됩니다.`; successMessage.value = `${formatServerDateTime(result.deleteAfter, { format: 'date' })}까지 정보가 보존됩니다.`;
await router.replace('/'); await router.replace('/');
}); });
}; };
@@ -411,7 +412,7 @@ onBeforeUnmount(() => {
</tr> </tr>
<tr> <tr>
<th class="legacy-bg1">가입일시</th> <th class="legacy-bg1">가입일시</th>
<td colspan="2">{{ new Date(account.createdAt).toLocaleString('ko-KR') }}</td> <td colspan="2">{{ formatServerDateTime(account.createdAt) }}</td>
<td colspan="3"> <td colspan="3">
개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }} 개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
<button <button
+39 -40
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
@@ -175,6 +176,9 @@ type AdminPublicUser = {
type AdminProfile = { type AdminProfile = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
status: string; status: string;
apiPort: number; apiPort: number;
@@ -435,8 +439,7 @@ const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean => const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED'; status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
const formatRuntimeActionTime = (value: string | null): string => const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
value ? new Date(value).toLocaleString('ko-KR') : '';
const userLookupMode = ref<'username' | 'id' | 'email'>('username'); const userLookupMode = ref<'username' | 'id' | 'email'>('username');
const userLookupValue = ref(''); const userLookupValue = ref('');
@@ -630,14 +633,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
} }
}; };
const toLocalInputValue = (value: string): string => { const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = (part: number): string => String(part).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(
date.getMinutes()
)}`;
};
const loadProfiles = async () => { const loadProfiles = async () => {
profilesLoading.value = true; profilesLoading.value = true;
@@ -795,7 +791,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined; const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
const scheduledAt = const scheduledAt =
action === 'RESET_SCHEDULED' && actionState?.scheduledAt action === 'RESET_SCHEDULED' && actionState?.scheduledAt
? new Date(actionState.scheduledAt).toISOString() ? serverDateTimeInputToIso(actionState.scheduledAt)
: undefined; : undefined;
const reason = actionState?.reason.trim() || undefined; const reason = actionState?.reason.trim() || undefined;
let runtimeActionId: string | undefined; let runtimeActionId: string | undefined;
@@ -952,7 +948,7 @@ const updateKakaoGrace = async (clear = false) => {
try { try {
const result = await adminClient.users.updateKakaoGrace.mutate({ const result = await adminClient.users.updateKakaoGrace.mutate({
userId: userResult.value.id, userId: userResult.value.id,
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(), until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
reason, reason,
}); });
userResult.value = { userResult.value = {
@@ -983,7 +979,9 @@ const grantSpecialAccess = async () => {
.map((profile) => profile.trim()) .map((profile) => profile.trim())
.filter(Boolean), .filter(Boolean),
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value, allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null, expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
: null,
reason, reason,
}); });
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id }); const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
@@ -1071,7 +1069,7 @@ const applyBan = async () => {
} }
const reason = requireUserActionReason(); const reason = requireUserActionReason();
if (!reason) return; if (!reason) return;
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null; const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
const patch = { const patch = {
bannedUntil: until, bannedUntil: until,
notes: banReason.value.trim() || undefined, notes: banReason.value.trim() || undefined,
@@ -1150,7 +1148,7 @@ const applyRestriction = async () => {
.filter(Boolean); .filter(Boolean);
const restriction = { const restriction = {
blockedFeatures: features.length ? features : undefined, blockedFeatures: features.length ? features : undefined,
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined, until: restrictionUntil.value ? serverDateTimeInputToIso(restrictionUntil.value) : undefined,
reason: restrictionReason.value.trim() || undefined, reason: restrictionReason.value.trim() || undefined,
notes: restrictionNotes.value.trim() || undefined, notes: restrictionNotes.value.trim() || undefined,
}; };
@@ -1213,7 +1211,7 @@ const scheduleDeleteUser = async () => {
reason, reason,
}); });
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter }; userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
forceDeleteStatus.value = `탈퇴 예약 완료: ${new Date(result.deleteAfter).toLocaleString('ko-KR')}`; forceDeleteStatus.value = `탈퇴 예약 완료: ${formatServerDateTime(result.deleteAfter)}`;
await Promise.all([refreshUserHistory(), loadUserDirectory()]); await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) { } catch (error) {
forceDeleteStatus.value = '탈퇴 예약 실패'; forceDeleteStatus.value = '탈퇴 예약 실패';
@@ -1357,7 +1355,7 @@ onMounted(() => {
<span class="block truncate">{{ user.email || '이메일 없음' }}</span> <span class="block truncate">{{ user.email || '이메일 없음' }}</span>
<span <span
>{{ user.oauthType }} · >{{ user.oauthType }} ·
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span {{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
> >
</span> </span>
<span class="flex flex-wrap gap-1 md:justify-end"> <span class="flex flex-wrap gap-1 md:justify-end">
@@ -1436,15 +1434,17 @@ onMounted(() => {
</div> </div>
<div class="text-xs text-zinc-500"> <div class="text-xs text-zinc-500">
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작: Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }} {{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
</div> </div>
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300"> <div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지 관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
</div> </div>
<div v-if="userResult.deleteAfter" class="text-xs text-red-300"> <div v-if="userResult.deleteAfter" class="text-xs text-red-300">
탈퇴 예약: {{ new Date(userResult.deleteAfter).toLocaleString('ko-KR') }} 탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
</div>
<div class="text-xs text-zinc-500">
가입일: {{ formatServerDateTime(userResult.createdAt) }}
</div> </div>
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
<div class="text-xs text-zinc-400 mt-2">제재 상태</div> <div class="text-xs text-zinc-400 mt-2">제재 상태</div>
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap" <pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
>{{ JSON.stringify(userResult.sanctions, null, 2) }} >{{ JSON.stringify(userResult.sanctions, null, 2) }}
@@ -1632,7 +1632,8 @@ onMounted(() => {
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4> <h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400"> <div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와 운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select <select
@@ -1696,11 +1697,11 @@ onMounted(() => {
</div> </div>
<div> <div>
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료 장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }} {{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
</div> </div>
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div> <div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
<div v-if="grant.revokedAt" class="text-red-300"> <div v-if="grant.revokedAt" class="text-red-300">
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} · 해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
{{ grant.revokedReason }} {{ grant.revokedReason }}
</div> </div>
</div> </div>
@@ -1713,7 +1714,8 @@ onMounted(() => {
> >
<h4 class="text-base font-semibold">Kakao 인증 유예</h4> <h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500"> <div class="text-xs text-zinc-500">
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div> </div>
<div class="flex flex-col md:flex-row gap-2"> <div class="flex flex-col md:flex-row gap-2">
<input <input
@@ -1762,11 +1764,7 @@ onMounted(() => {
<td class="text-center">{{ policy.accessGraceDays }}</td> <td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td> <td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
<td class="text-center"> <td class="text-center">
{{ {{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
policy.graceEndsAt
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
: '-'
}}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -1778,7 +1776,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'" v-if="userWorkspaceSection === 'restrictions'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4" class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
> >
<h4 class="text-base font-semibold">유저 차단</h4> <h4 class="text-base font-semibold">유저 차단 (서버 시간 UTC+9)</h4>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<input <input
v-model="banUntil" v-model="banUntil"
@@ -1817,7 +1815,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'" v-if="userWorkspaceSection === 'restrictions'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4" class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
> >
<h4 class="text-base font-semibold">서버별 기능 제재</h4> <h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
<div class="grid gap-2"> <div class="grid gap-2">
<input <input
v-model="restrictionProfile" v-model="restrictionProfile"
@@ -1936,9 +1934,7 @@ onMounted(() => {
> >
{{ event.outcome }} · {{ event.action }} {{ event.outcome }} · {{ event.action }}
</span> </span>
<span class="text-zinc-500">{{ <span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
</div> </div>
<div class="text-zinc-400"> <div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }} {{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
@@ -1988,9 +1984,7 @@ onMounted(() => {
> >
{{ event.outcome }} · {{ event.action }} {{ event.outcome }} · {{ event.action }}
</span> </span>
<span class="text-zinc-500">{{ <span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
</div> </div>
<div class="text-zinc-400"> <div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }} {{ event.actorUsername }} · {{ event.targetType ?? '-' }}
@@ -2067,9 +2061,14 @@ onMounted(() => {
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div> <div>
<div class="text-base font-semibold"> <div class="text-base font-semibold">
{{ profile.profileName }} ({{ profile.profile }}) {{ profile.meta.korName ?? profile.profile }}
</div>
<div class="text-xs text-zinc-500">
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
</div>
<div class="text-xs text-zinc-500">
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
</div> </div>
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
</div> </div>
<div class="text-xs text-zinc-400"> <div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / 상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
+2 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, ref, onMounted, watch } from 'vue'; import { computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server'; import type { inferRouterOutputs } from '@trpc/server';
@@ -95,8 +96,7 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
tabButtons?.[nextIndex]?.focus(); tabButtons?.[nextIndex]?.focus();
}; };
const formatGraceEndsAt = (value: string | null | undefined): string => const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
value ? new Date(value).toLocaleString('ko-KR') : '';
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const encodeLegacyIconPath = (value: string): string => const encodeLegacyIconPath = (value: string): string =>
value value
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
@@ -213,21 +214,11 @@ const sourceHelp = computed(() =>
); );
const toIso = (value: string): string | undefined => { const toIso = (value: string): string | undefined => {
if (!value) { return serverDateTimeInputToIso(value);
return undefined;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}; };
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-'); const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
const formatLogTime = (value: string): string => const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
new Date(value).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-'); const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => { const clearStatus = () => {
@@ -959,7 +950,7 @@ onBeforeUnmount(() => {
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3"> <div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400" <label class="text-xs text-zinc-400"
>작업 예약 >작업 예약 (서버 시간 UTC+9)
<input <input
v-model="form.scheduledAt" v-model="form.scheduledAt"
type="datetime-local" type="datetime-local"
@@ -967,7 +958,7 @@ onBeforeUnmount(() => {
/> />
</label> </label>
<label class="text-xs text-zinc-400" <label class="text-xs text-zinc-400"
>가오픈 >가오픈 (서버 시간 UTC+9)
<input <input
v-model="form.preopenAt" v-model="form.preopenAt"
type="datetime-local" type="datetime-local"
@@ -975,7 +966,7 @@ onBeforeUnmount(() => {
/> />
</label> </label>
<label class="text-xs text-zinc-400" <label class="text-xs text-zinc-400"
>정식 오픈 >정식 오픈 (서버 시간 UTC+9)
<input <input
v-model="form.openAt" v-model="form.openAt"
type="datetime-local" type="datetime-local"
@@ -1302,10 +1293,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span> <span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table <table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
class="w-full min-w-[1300px] table-fixed text-left text-sm"
data-testid="operations-table"
>
<colgroup> <colgroup>
<col style="width: 160px" /> <col style="width: 160px" />
<col style="width: 264px" /> <col style="width: 264px" />
+5 -1
View File
@@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와 - 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과 시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다. `aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
- `profileName``${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
`che:default``default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가 - 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
@@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화 업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다. 권한과 버전 배포 권한이 모두 필요합니다.
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와 - 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은 분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이 그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다. 실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
- 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults` - 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults`
+7
View File
@@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
확인합니다. 확인합니다.
6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다. 6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다.
Gateway migration은 앱 rollback 때 자동으로 역방향 적용되지 않습니다. 따라서
profile identity 분리의 첫 단계는 기존 `profile_name`과 legacy `scenario`를 유지한
`instance_key`와 nullable `current_scenario`를 추가합니다. DB trigger가 구버전의
`scenario` write와 신버전의 `current_scenario` write를 양방향 동기화하므로 migration
적용 뒤 readiness가 실패해 직전 Gateway worktree로 돌아가도 기존 DB와 시즌을
그대로 사용할 수 있습니다.
release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`, release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`,
`pm_exec_path`, `name`, `NODE_APP_INSTANCE``axm_*` 같은 PM2 내부 값을 자식 `pm_exec_path`, `name`, `NODE_APP_INSTANCE``axm_*` 같은 PM2 내부 값을 자식
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의 환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
+1
View File
@@ -1,6 +1,7 @@
export * from './rng.js'; export * from './rng.js';
export * from './time/Clock.js'; export * from './time/Clock.js';
export * from './time/GameClock.js'; export * from './time/GameClock.js';
export * from './time/ServerDateTime.js';
export * from './util/BytesLike.js'; export * from './util/BytesLike.js';
export * from './util/convertBytesLikeToArrayBuffer.js'; export * from './util/convertBytesLikeToArrayBuffer.js';
export * from './util/convertBytesLikeToUint8Array.js'; export * from './util/convertBytesLikeToUint8Array.js';
+158
View File
@@ -0,0 +1,158 @@
const SERVER_UTC_OFFSET_MINUTES = 9 * 60;
const SERVER_UTC_OFFSET_MS = SERVER_UTC_OFFSET_MINUTES * 60_000;
export type ServerDateTimeFormat =
| 'dateTimeSeconds'
| 'dateTimeMinutes'
| 'date'
| 'timeSeconds'
| 'hourMinute'
| 'minuteSecond'
| 'monthDayTime'
| 'monthDayTimeSeconds';
export type ServerDateTimeOptions = {
format?: ServerDateTimeFormat;
fallback?: string;
};
type DateTimeParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
millisecond: number;
};
const SERVER_WALL_TIME_PATTERN = /^(\d{4,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/u;
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
const isValidParts = (parts: DateTimeParts): boolean => {
const candidate = new Date(0);
candidate.setUTCFullYear(parts.year, parts.month - 1, parts.day);
candidate.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
return (
candidate.getUTCFullYear() === parts.year &&
candidate.getUTCMonth() + 1 === parts.month &&
candidate.getUTCDate() === parts.day &&
candidate.getUTCHours() === parts.hour &&
candidate.getUTCMinutes() === parts.minute &&
candidate.getUTCSeconds() === parts.second &&
candidate.getUTCMilliseconds() === parts.millisecond
);
};
const parseServerWallTime = (value: string): DateTimeParts | null => {
const match = SERVER_WALL_TIME_PATTERN.exec(value.trim());
if (!match) {
return null;
}
const millisecondText = match[7] ?? '';
const parts: DateTimeParts = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] ?? 0),
minute: Number(match[5] ?? 0),
second: Number(match[6] ?? 0),
millisecond: Number(millisecondText.padEnd(3, '0')),
};
return isValidParts(parts) ? parts : null;
};
const partsFromInstant = (value: string | Date): DateTimeParts | null => {
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const shifted = new Date(date.getTime() + SERVER_UTC_OFFSET_MS);
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth() + 1,
day: shifted.getUTCDate(),
hour: shifted.getUTCHours(),
minute: shifted.getUTCMinutes(),
second: shifted.getUTCSeconds(),
millisecond: shifted.getUTCMilliseconds(),
};
};
const resolveParts = (value: string | Date): DateTimeParts | null => {
if (typeof value === 'string') {
const wallTime = parseServerWallTime(value);
if (wallTime) {
return wallTime;
}
}
return partsFromInstant(value);
};
const formatParts = (parts: DateTimeParts, format: ServerDateTimeFormat): string => {
const year = pad(parts.year, 4);
const month = pad(parts.month);
const day = pad(parts.day);
const hour = pad(parts.hour);
const minute = pad(parts.minute);
const second = pad(parts.second);
switch (format) {
case 'dateTimeMinutes':
return `${year}-${month}-${day} ${hour}:${minute}`;
case 'date':
return `${year}-${month}-${day}`;
case 'timeSeconds':
return `${hour}:${minute}:${second}`;
case 'hourMinute':
return `${hour}:${minute}`;
case 'minuteSecond':
return `${minute}:${second}`;
case 'monthDayTime':
return `${month}-${day} ${hour}:${minute}`;
case 'monthDayTimeSeconds':
return `${month}-${day} ${hour}:${minute}:${second}`;
case 'dateTimeSeconds':
default:
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
};
/**
* Formats an instant in the service's fixed UTC+9 wall clock.
*
* Timezone-less legacy DATETIME strings are already server wall-clock values and
* therefore keep their components. This deliberate fixed offset also avoids
* historical IANA timezone rules changing ancient in-game years.
*/
export const formatServerDateTime = (
value: string | Date | null | undefined,
options: ServerDateTimeOptions = {}
): string => {
if (value === null || value === undefined || value === '') {
return options.fallback ?? '';
}
const parts = resolveParts(value);
if (!parts) {
return options.fallback ?? String(value);
}
return formatParts(parts, options.format ?? 'dateTimeSeconds');
};
export const toServerDateTimeInputValue = (value: string | Date | null | undefined): string => {
const formatted = formatServerDateTime(value, { format: 'dateTimeMinutes', fallback: '' });
return formatted ? formatted.replace(' ', 'T') : '';
};
/** Converts an HTML datetime-local value, interpreted as UTC+9 server wall time, to ISO UTC. */
export const serverDateTimeInputToIso = (value: string): string | undefined => {
const parts = parseServerWallTime(value);
if (!parts) {
return undefined;
}
const wallTime = new Date(0);
wallTime.setUTCFullYear(parts.year, parts.month - 1, parts.day);
wallTime.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
return new Date(wallTime.getTime() - SERVER_UTC_OFFSET_MS).toISOString();
};
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
formatServerDateTime,
serverDateTimeInputToIso,
toServerDateTimeInputValue,
} from '../src/time/ServerDateTime.js';
describe('formatServerDateTime', () => {
it('formats ISO instants with the fixed UTC+9 service offset', () => {
expect(formatServerDateTime('2026-08-13T00:05:06.000Z')).toBe('2026-08-13 09:05:06');
expect(formatServerDateTime('0185-01-02T00:04:05.000Z')).toBe('0185-01-02 09:04:05');
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'date' })).toBe('2026-08-14');
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'hourMinute' })).toBe('03:05');
});
it('preserves timezone-less legacy wall-clock values', () => {
expect(formatServerDateTime('0185-01-02 03:04:05')).toBe('0185-01-02 03:04:05');
expect(formatServerDateTime('0185-01-02T03:04:05', { format: 'monthDayTime' })).toBe('01-02 03:04');
expect(formatServerDateTime('2026-08-13 09:05:06', { format: 'minuteSecond' })).toBe('05:06');
});
it('offers explicit shapes and predictable fallbacks', () => {
const value = '2026-08-13T00:05:06.000Z';
expect(formatServerDateTime(value, { format: 'dateTimeMinutes' })).toBe('2026-08-13 09:05');
expect(formatServerDateTime(value, { format: 'timeSeconds' })).toBe('09:05:06');
expect(formatServerDateTime(value, { format: 'monthDayTimeSeconds' })).toBe('08-13 09:05:06');
expect(formatServerDateTime(undefined, { fallback: '-' })).toBe('-');
expect(formatServerDateTime('not-a-date')).toBe('not-a-date');
});
});
describe('server datetime-local conversion', () => {
it('does not depend on the browser or process timezone', () => {
expect(serverDateTimeInputToIso('2026-08-13T09:05')).toBe('2026-08-13T00:05:00.000Z');
expect(toServerDateTimeInputValue('2026-08-13T00:05:00.000Z')).toBe('2026-08-13T09:05');
});
it('rejects invalid local input', () => {
expect(serverDateTimeInputToIso('2026-02-30T09:05')).toBeUndefined();
expect(serverDateTimeInputToIso('')).toBeUndefined();
expect(toServerDateTimeInputValue('not-a-date')).toBe('');
});
});
@@ -0,0 +1,71 @@
-- Keep profile_name stable because it is referenced by operations, runtime
-- actions, permission scopes, process names, Redis namespaces, and routes.
-- instance_key identifies the immutable slot while current_scenario records the
-- mutable game selection. The legacy scenario column remains during the
-- expansion phase so the previous Gateway release can still be restored.
ALTER TABLE "gateway_profile"
ADD COLUMN "instance_key" TEXT,
ADD COLUMN "current_scenario" TEXT;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM "gateway_profile"
WHERE left("profile_name", length("profile") + 1) <> "profile" || ':'
) THEN
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
END IF;
END
$$;
UPDATE "gateway_profile"
SET
"instance_key" = substring("profile_name" FROM length("profile") + 2),
"current_scenario" = NULLIF("scenario", 'default');
ALTER TABLE "gateway_profile"
ALTER COLUMN "instance_key" SET NOT NULL;
DROP INDEX "gateway_profile_profile_scenario_key";
ALTER TABLE "gateway_profile"
ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"),
ADD CONSTRAINT "gateway_profile_identity_check"
CHECK (
length("instance_key") > 0
AND "profile_name" = "profile" || ':' || "instance_key"
);
CREATE FUNCTION "sync_gateway_profile_scenario_compat"()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW."instance_key" IS NULL THEN
IF left(NEW."profile_name", length(NEW."profile") + 1) <> NEW."profile" || ':' THEN
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
END IF;
NEW."instance_key" := substring(NEW."profile_name" FROM length(NEW."profile") + 2);
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW."current_scenario" IS NULL THEN
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
ELSE
NEW."scenario" := NEW."current_scenario";
END IF;
ELSIF NEW."current_scenario" IS DISTINCT FROM OLD."current_scenario" THEN
NEW."scenario" := COALESCE(NEW."current_scenario", 'default');
ELSIF NEW."scenario" IS DISTINCT FROM OLD."scenario" THEN
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER "gateway_profile_scenario_compat"
BEFORE INSERT OR UPDATE ON "gateway_profile"
FOR EACH ROW
EXECUTE FUNCTION "sync_gateway_profile_scenario_compat"();
+5 -1
View File
@@ -208,6 +208,10 @@ model LegacyRootKeyValue {
model GatewayProfile { model GatewayProfile {
profileName String @id @map("profile_name") profileName String @id @map("profile_name")
profile String profile String
instanceKey String @map("instance_key")
currentScenario String? @map("current_scenario")
/// Legacy compatibility mirror. The database trigger keeps this synchronized
/// with currentScenario while the previous Gateway release remains rollbackable.
scenario String scenario String
apiPort Int @map("api_port") apiPort Int @map("api_port")
status GatewayProfileStatus status GatewayProfileStatus
@@ -229,7 +233,7 @@ model GatewayProfile {
operations GatewayOperation[] operations GatewayOperation[]
runtimeActions GatewayRuntimeAction[] runtimeActions GatewayRuntimeAction[]
@@unique([profile, scenario]) @@unique([profile, instanceKey])
@@map("gateway_profile") @@map("gateway_profile")
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs", "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
"gameSchemaHead": "20260803000000_add_logical_game_clock", "gameSchemaHead": "20260803000000_add_logical_game_clock",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }
@@ -293,11 +293,14 @@ test('renders actual online, nation policy, and survey data with ref geometry an
await expect(status).toContainText(marker); await expect(status).toContainText(marker);
await expect(page.locator('.online-users')).toContainText('현황검증장수'); await expect(page.locator('.online-users')).toContainText('현황검증장수');
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.'); await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('.vote-status')).toHaveText('설문: 검증 설문');
const desktop = await status.evaluate((element) => { const desktop = await status.evaluate((element) => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
const onlineRow = element.querySelector<HTMLElement>('.online-nations'); const onlineRow = element.querySelector<HTMLElement>('.online-nations');
const voteRow = element.querySelector<HTMLElement>('.vote-status'); const voteRow = element.querySelector<HTMLElement>('.vote-status');
if (!onlineRow || !voteRow) throw new Error('status row missing'); const tournamentRow = element.querySelector<HTMLElement>('.tournament-status');
if (!onlineRow || !voteRow || !tournamentRow) throw new Error('status row missing');
return { return {
rect: element.getBoundingClientRect().toJSON(), rect: element.getBoundingClientRect().toJSON(),
fontSize: style.fontSize, fontSize: style.fontSize,
@@ -308,6 +311,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
borderTop: getComputedStyle(onlineRow).borderTop, borderTop: getComputedStyle(onlineRow).borderTop,
padding: getComputedStyle(onlineRow).padding, padding: getComputedStyle(onlineRow).padding,
}, },
tournamentRow: tournamentRow.getBoundingClientRect().toJSON(),
voteRow: voteRow.getBoundingClientRect().toJSON(), voteRow: voteRow.getBoundingClientRect().toJSON(),
}; };
}); });
@@ -321,6 +325,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an
}, },
}); });
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0); expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
expect(desktop.tournamentRow.x).toBeCloseTo(333.33, 0);
expect(desktop.tournamentRow.width).toBeCloseTo(333.33, 0);
expect(desktop.tournamentRow.height).toBeCloseTo(36, 0);
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0); expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0); expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
expect(desktop.voteRow.height).toBeCloseTo(36, 0); expect(desktop.voteRow.height).toBeCloseTo(36, 0);
@@ -328,6 +335,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect(status).toHaveCSS('width', '500px'); await expect(status).toHaveCSS('width', '500px');
await expect(page.locator('.tournament-status')).toHaveCSS('width', '250px');
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px'); await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
failStatus = true; failStatus = true;
@@ -0,0 +1,140 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3416/sam/';
const username = process.env.REF_GENERAL_USER ?? 's100user01';
const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-nation-general-controls');
if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const measure = async (page) =>
page.evaluate(() => {
const describe = (element) => {
if (!(element instanceof HTMLElement)) return null;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
borderCollapse: style.borderCollapse,
},
};
};
const headerGroups = [...document.querySelectorAll('.ag-header-group-cell')].map((element) => ({
text: element.textContent?.trim() ?? '',
className: element.className,
expanded: element.getAttribute('aria-expanded'),
rect: describe(element)?.rect,
}));
const columns = [...document.querySelectorAll('.ag-header-cell')].map((element) => ({
id: element.getAttribute('col-id'),
text: element.textContent?.trim() ?? '',
sort: element.getAttribute('aria-sort'),
}));
const inputs = [...document.querySelectorAll('.ag-header-row-column-filter input.ag-text-field-input')].map(
(element) => {
const rect = element.getBoundingClientRect();
const center = rect.x + rect.width / 2;
const matchingHeader = [...document.querySelectorAll('.ag-header-row-column .ag-header-cell')].find(
(candidate) => {
const headerRect = candidate.getBoundingClientRect();
return center >= headerRect.x && center <= headerRect.right;
}
);
return {
colId: matchingHeader?.getAttribute('col-id') ?? null,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
placeholder: element.getAttribute('placeholder'),
};
}
);
return {
url: location.href,
documentWidth: document.documentElement.scrollWidth,
body: describe(document.body),
page: describe(document.querySelector('.pageNationGeneral')),
component: describe(document.querySelector('.component-general-list')),
grid: describe(document.querySelector('.ag-root-wrapper')),
firstRow: describe(document.querySelector('.ag-row')),
headerGroups,
columns,
inputs,
toolbarText: document.querySelector('.component-general-list')?.textContent?.slice(0, 500) ?? '',
};
});
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const salt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(salt + password + salt)
.digest('hex');
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const loginResult = await login.json();
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
await page.goto(new URL('hwe/v_nationGeneral.php', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
await page.locator('.ag-root-wrapper').waitFor();
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
await page.evaluate(() => document.fonts.ready);
const output = { initial: await measure(page) };
await page.screenshot({ path: resolve(artifactRoot, 'ref-initial.png'), fullPage: true });
const statGroup = page.locator('.ag-header-group-cell').filter({ hasText: '능력치' });
await statGroup.locator('.ag-header-expand-icon:visible').click();
output.collapsed = await measure(page);
await page.screenshot({ path: resolve(artifactRoot, 'ref-stat-collapsed.png'), fullPage: true });
const leadershipHeader = page.locator('.ag-header-cell[col-id="leadership"] .ag-header-cell-label');
await statGroup.locator('.ag-header-expand-icon:visible').click();
await leadershipHeader.click();
output.sort = {
ariaSort: await page.locator('.ag-header-cell[col-id="leadership"]').getAttribute('aria-sort'),
firstValue: await page.locator('.ag-center-cols-container .ag-row [col-id="leadership"]').first().innerText(),
};
await statGroup.locator('.ag-header-expand-icon:visible').click();
await page.getByRole('button', { name: /보기 모드/ }).click();
page.once('dialog', (dialog) => dialog.accept('Ref 캡처'));
await page.getByText('보관하기', { exact: true }).click();
output.savedSetting = await page.evaluate(() => ({
settings: localStorage.getItem('GeneralListDisplaySetting'),
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
}));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.ag-root-wrapper').waitFor();
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
output.reloaded = await measure(page);
await page.getByRole('button', { name: /보기 모드/ }).click();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: '삭제', exact: true }).click();
output.deletedSetting = await page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting'));
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
} finally {
await browser.close();
}