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.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 }) => {
await getMyGeneral(ctx);
@@ -177,7 +194,16 @@ export const tournamentRouter = router({
}
const generals = await ctx.db.general.findMany({
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) => {
@@ -201,6 +227,8 @@ export const tournamentRouter = router({
generalId: general.id,
name: general.name,
npcState: general.npcState,
picture: general.picture,
imageServer: general.imageServer,
stat,
games: win + draw + lose,
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;
};
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 (
worldState: WorldStateRow,
general: GeneralRow,
@@ -183,7 +201,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
};
for (const item of moduleBundle.itemModules) {
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 = {
@@ -205,11 +235,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
crewTypes: (environment.unitSet.crewTypes ?? [])
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
.map((entry) => ({ value: entry.id, label: entry.name })),
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
value: Number(value),
label,
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
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) => ({
value: index,
label: `색상 ${index + 1}`,
@@ -226,6 +264,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
unitSet: environment.unitSet,
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({
+42 -12
View File
@@ -15,6 +15,7 @@ export interface TurnCommandOption {
value: TurnCommandOptionValue;
label: string;
color?: string;
description?: string;
}
export interface TurnCommandRecruitmentCrewType {
@@ -50,14 +51,7 @@ export interface TurnCommandRecruitmentInfo {
}
export type TurnCommandOptionSource =
| 'cities'
| 'nations'
| 'generals'
| 'crewTypes'
| 'armTypes'
| 'nationTypes'
| 'colors'
| 'items';
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
export interface TurnCommandInputField {
key: string;
@@ -83,14 +77,50 @@ export interface TurnCommandInputOptions {
colors: TurnCommandOption[];
items: Record<string, TurnCommandOption[]>;
recruitment: TurnCommandRecruitmentInfo | null;
context?: {
actorGold: number;
actorRice: number;
citySecurity?: number;
nationGold?: number;
nationRice?: number;
nationLevel?: number;
};
}
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
export const TURN_COMMAND_NATION_COLORS = [
'#FF0000', '#800000', '#A0522D', '#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',
'#FF0000',
'#800000',
'#A0522D',
'#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',
] as const;
@@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
id,
userId,
name: `장수${id}`,
picture: `${id}.jpg`,
imageServer: id % 2,
leadership: 70 + id,
strength: 60 + id,
intel: 50 + id,
@@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => {
const sections = await ownerCaller.tournament.getRankings();
expect(sections).toHaveLength(4);
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(
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' });
});
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 () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => {
create: {
profileName,
profile: 'runtime',
instanceKey: 'consumer-integration',
currentScenario: 'consumer-integration',
scenario: 'consumer-integration',
apiPort: 15998,
status: 'RUNNING',
+21 -5
View File
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
'src',
'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) {
await page.screenshot({
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();
});
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 = {
permission: 2,
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-content').fill('새 내용');
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('role', 'alert');
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 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.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 });
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
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
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom))
.poll(async () =>
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
)
.toBeGreaterThanOrEqual(0);
const mobileToastGeometry = await commentToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
+156 -9
View File
@@ -15,11 +15,11 @@ const operations = (route: Route) =>
const inputOptions = {
cities: [
{ value: 1, label: '업 (아국)' },
{ value: 2, label: '허창 (적국)' },
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
],
nations: [
{ value: 1, label: '아국', color: '#008000' },
{ value: 2, label: '적국', color: '#800000' },
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
],
generals: [
{ value: 1, label: '장수 (아국 · 업)' },
@@ -75,6 +75,14 @@ const inputOptions = {
},
],
},
context: {
actorGold: 1000,
actorRice: 1000,
citySecurity: 500,
nationGold: 5000,
nationRice: 6000,
nationLevel: 1,
},
};
const commandTable = {
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,
};
@@ -177,8 +206,46 @@ const generalContext = {
dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
},
city: { id: 1, name: '업', level: 8, region: 1, population: 1000, populationMax: 2000 },
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
city: {
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: {},
penalties: {},
};
@@ -247,8 +314,11 @@ const install = async (page: Page, rejectGeneral = false) => {
if (name === 'world.getMapLayout')
return response({
mapName: 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [] }],
regionMap: { 1: '하북' },
cityList: [
{ 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: '특' },
});
if (name === 'auth.status') return response({ ok: true });
@@ -271,8 +341,14 @@ const install = async (page: Page, rejectGeneral = false) => {
startYear: 180,
year: 200,
month: 1,
cityList: [[1, 8, 0, 1, 1, 1]],
nationList: [[1, '아국', '#008000', 1]],
cityList: [
[1, 8, 0, 1, 1, 1],
[2, 7, 40, 2, 2, 1],
],
nationList: [
[1, '아국', '#008000', 1],
[2, '적국', '#800000', 2],
],
spyList: {},
shownByGeneralList: [],
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 }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
const form = page.getByTestId('command-argument-form');
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 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('"destGeneralId":2');
expect(mapGeometry.width).toBeGreaterThan(650);
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
expect(geometry.width).toBeGreaterThan(200);
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
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금');
});
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 }) => {
await install(page, true);
await page.goto('/');
+63 -14
View File
@@ -1,6 +1,6 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
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';
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 = {
permission: 'head' | 'member';
myset: number;
@@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
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).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 rect = element.getBoundingClientRect();
@@ -587,9 +610,9 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
await expect(nationCard).toContainText('국가 등급주자사');
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('계급29품관');
await expect(generalCard).toContainText('계급 29품관');
const cityCard = page.locator('.city-card');
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
@@ -646,11 +669,19 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
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: [] };
await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
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('병종 보병');
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.minWidth).toBe('500px');
expect(desktop.minWidth).toBe('0px');
expect(desktop.fontSize).toBe('14px');
expect(desktop.columns.split(' ')).toHaveLength(2);
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');
}
await page.setViewportSize({ width: 500, height: 900 });
await page.setViewportSize({ width: 390, height: 900 });
await page.reload();
const mobile = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
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 {
width: rect.width,
scrollWidth: document.documentElement.scrollWidth,
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
settingsOffset: settings.x - rect.x,
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({
width: 500,
scrollWidth: 500,
columns: '500px',
width: 390,
scrollWidth: 390,
columns: '390px',
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);
});
@@ -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 page.getByRole('button', { name: '다음 ▶' }).click();
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('병종보병');
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')).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 [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
expect(
+240 -3
View File
@@ -34,6 +34,8 @@ type NavigationFixture = {
largeCommandTable?: boolean;
currentYear?: number;
currentMonth?: number;
scenarioTitle?: string;
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
globalRecords?: Array<{ id: number; text: string }>;
generalRecords?: 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,
month: state.currentMonth ?? 1,
turnTerm: 10,
scenarioTitle: state.scenarioTitle ?? '',
});
}
if (operation === 'dashboard.getContextBundleDelta') {
@@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
onlineGenerals: '메뉴검증장수',
nationNotice: '<p>국가 방침</p>',
lastExecuted: null,
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false },
latestVote:
state.latestVote === undefined
? { id: 9, title: '메뉴 설문', hasVoted: false }
: state.latestVote,
});
}
if (operation === 'board.getAccess') {
@@ -508,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
const waitForMain = async (page: Page) => {
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-nation-menu')).toBeVisible();
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'),
nationPopup: describe('#mobile-nation-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([
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
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,
stage: 1,
npcMode: 1,
scenarioTitle: '메인 화면 검증 시나리오',
generalMeCalls: 0,
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('.layout-desktop')).toBeVisible();
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
.locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]')
.evaluateAll((elements) =>
@@ -642,7 +704,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(gameInfoButton).toBeFocused();
await gameInfoButton.click();
await page.getByRole('heading', { name: '전장 현황' }).click();
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
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();
const quickPicker = page.getByTestId('command-picker');
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 row = element
.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.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
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();
const picker = page.getByTestId('command-picker');
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(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
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');
});
@@ -1121,6 +1216,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
nationLevel: 3,
stage: 6,
npcMode: 1,
scenarioTitle: '모바일 검증 시나리오',
latestVote: null,
generalMeCalls: 0,
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 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
.poll(() =>
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`);
});
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 }) => {
const state: NavigationFixture = {
officerLevel: 1,
@@ -18,6 +18,8 @@ const general = {
stats: { leadership: 70, strength: 60, intelligence: 50 },
experienceLevel: 9,
dedicationLevel: 1,
dedicationText: '30품관',
bill: 600,
injury: 0,
gold: 1000,
rice: 2000,
@@ -28,6 +30,24 @@ const general = {
refreshScoreTotal: 10,
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) => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_general');
@@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => {
return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 0 },
generals: [general],
generals: [general, otherGeneral],
});
if (operation === 'nation.getSecretGeneralList') {
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.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
await expect(page.locator('#nation-general-list')).toContainText('?');
const computed = await page.locator('.general-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
});
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
expect(computed.fontFamily).toContain('Times New Roman');
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' });
expect(computed.fontFamily).toContain('Pretendard');
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
'separate'
);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000);
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 }) => {
+130 -6
View File
@@ -1,15 +1,22 @@
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 { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR;
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, '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 = [
'관우',
'장료',
@@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({
strength: 80,
intel: 80,
level: 10,
picture: 'default.jpg',
imageServer: 0,
groupId: 10 + (index % 8),
groupNo: Math.floor(index / 8),
win: 3 - (index % 2),
@@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
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) => {
await page.addInitScript((profile) => {
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 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) => {
const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') return response({ ok: true });
@@ -125,12 +157,43 @@ const installFixture = async (page: Page) => {
}
if (operation === 'tournament.getBettingSummary') {
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: {},
totalAmount: 2800,
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);
});
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,
championCenter: champion.x + champion.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(geometry.finalistCenters).toHaveLength(2);
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).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) => {
@@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a
expect(bounds.width).toBe(390);
expect(bounds.minX).toBeGreaterThanOrEqual(0);
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">
<head>
<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>
</head>
<body class="bg-black text-white">
+7
View File
@@ -39,6 +39,13 @@ body {
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(.chief-page),
body:has(.global-page),
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed } from '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<{
officerLevelText: string;
@@ -13,6 +19,8 @@ const props = defineProps<{
generalId: number;
officerLevel: number;
mobile?: boolean;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
@@ -37,6 +45,8 @@ const emit = defineEmits<{
:title="props.officerLevelText"
:name="props.name"
:current-time="props.rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('reserve-bulk', $event)"
@shift="emit('shift', $event)"
@repeat="emit('repeat', $event)"
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue';
import { commandArgumentPresentation } from './commandArgumentPresentation';
import DragSelect from './DragSelect.vue';
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
import {
@@ -12,7 +13,14 @@ import {
normalizedSelection,
selectStep,
} from './commandQueue';
import type { CommandAvailability, CommandPatternEntry, CommandTable, ReservedCommandRow } from './types';
import type {
CommandAvailability,
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from './types';
const props = withDefaults(
defineProps<{
@@ -27,8 +35,19 @@ const props = withDefaults(
title?: string;
name?: string | null;
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<{
@@ -153,6 +172,14 @@ const closePicker = () => {
quickTarget.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 command = props.commandTable?.[props.scope]
.flatMap((group) => group.values)
@@ -242,7 +269,15 @@ const clickOutsideMenu = (event: Event) => {
<template>
<article
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"
>
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
@@ -309,6 +344,7 @@ const clickOutsideMenu = (event: Event) => {
>
짝수턴
</button>
<hr class="menu-divider" />
<template v-for="step in [3, 4, 5, 6, 7]" :key="step">
<small>{{ step }} 간격</small>
<div class="step-buttons">
@@ -433,6 +469,7 @@ const clickOutsideMenu = (event: Event) => {
>
붙여넣기
</button>
<hr class="menu-divider" />
<button
@click="
textCopy();
@@ -441,6 +478,7 @@ const clickOutsideMenu = (event: Event) => {
>
텍스트 복사
</button>
<hr class="menu-divider" />
<button
@click="
saveTemplate();
@@ -457,6 +495,7 @@ const clickOutsideMenu = (event: Event) => {
>
반복하기
</button>
<hr class="menu-divider" />
<button
@click="
clearSelection();
@@ -483,7 +522,7 @@ const clickOutsideMenu = (event: Event) => {
</button>
</div>
</details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button>
<button type="button" class="select-command" @click="togglePicker()">명령 선택 </button>
</div>
<div class="queue-area">
@@ -546,7 +585,7 @@ const clickOutsideMenu = (event: Event) => {
:key="row.index"
type="button"
:aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)"
@click="togglePicker(row.index)"
>
</button>
@@ -606,6 +645,8 @@ const clickOutsideMenu = (event: Event) => {
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
@@ -725,6 +766,14 @@ const clickOutsideMenu = (event: Event) => {
padding: 5px 8px;
color: #bbb;
}
.menu-divider {
width: 100%;
height: 0;
margin: 4px 0;
border: 0;
border-top: 1px solid #444;
opacity: 1;
}
.step-buttons,
.template-row {
display: flex;
@@ -933,6 +982,11 @@ const clickOutsideMenu = (event: Event) => {
}
@media (min-width: 1025px) {
.argument-expanded:not(.compact) .command-picker {
right: 0;
left: auto;
width: 700px;
}
.compact:not(.mobile) .command-picker {
position: fixed;
z-index: 1000;
@@ -941,6 +995,13 @@ const clickOutsideMenu = (event: Event) => {
left: calc(50% - 476px);
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 {
top: 76px;
left: 50%;
@@ -984,12 +1045,25 @@ const clickOutsideMenu = (event: Event) => {
width: 370px;
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 {
position: fixed;
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
margin-top: 0;
overflow: auto;
transform: none;
}
.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 = {
key: string;
@@ -69,6 +101,7 @@ export type CommandTable = {
colors: CommandOption[];
items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null;
context?: CommandInputContext;
};
};
@@ -1,40 +1,24 @@
<script setup lang="ts">
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;
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[]>;
}
type CommandInputOptions = CommandTable['inputOptions'];
const props = defineProps<{
commandKey: string;
fields: CommandInputField[];
options: CommandInputOptions;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const emit = defineEmits<{
@@ -43,6 +27,8 @@ const emit = defineEmits<{
}>();
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[] => {
if (field.options) return field.options;
@@ -58,7 +44,16 @@ const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'boolean') return true;
if (field.kind === 'numberTuple') return [field.min ?? 0, 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 '';
};
@@ -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 tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
tuple[index] = Number(rawValue);
@@ -89,17 +207,32 @@ const isValid = computed(() =>
const value = values[field.key];
if (field.kind === 'text') {
const length = typeof value === 'string' ? value.trim().length : 0;
return (!field.required || length > 0) && (field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max);
return (
(!field.required || length > 0) &&
(field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max)
);
}
if (field.kind === 'number') {
return typeof value === 'number' && Number.isFinite(value) &&
(field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max);
return (
typeof value === 'number' &&
Number.isFinite(value) &&
(field.min === undefined || value >= field.min) &&
(field.max === undefined || value <= field.max)
);
}
if (field.kind === 'numberTuple') {
return Array.isArray(value) && value.length === 2 &&
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) && (field.max === undefined || entry <= field.max));
return (
Array.isArray(value) &&
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);
return value !== undefined;
@@ -119,11 +252,28 @@ watch(
<template>
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
<div
v-for="field in props.fields.filter((entry) => entry.kind !== 'hidden')"
:key="field.key"
class="argument-row"
>
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
<MapViewer
:map-data="props.mapData ?? null"
: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>
<input
v-if="field.kind === 'text'"
@@ -182,6 +332,21 @@ watch(
/>
</label>
</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 v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div>
@@ -193,6 +358,43 @@ watch(
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 {
display: grid;
grid-template-columns: minmax(76px, 0.36fr) 1fr;
@@ -200,6 +402,23 @@ watch(
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) {
background: rgba(255, 255, 255, 0.035);
}
@@ -2,7 +2,13 @@
import { computed } from 'vue';
import { addMinutes } from 'date-fns';
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<{
commandTable: CommandTable | null;
@@ -14,6 +20,8 @@ const props = defineProps<{
turnTermMinutes?: number;
autorunLimit?: number | null;
storageKey?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const emit = defineEmits<{
@@ -63,6 +71,8 @@ const rows = computed<ReservedCommandRow[]>(() => {
:loading="props.loading"
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
:current-time="rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)"
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface GeneralStats {
leadership: number;
@@ -19,9 +22,18 @@ interface GeneralProgression {
statUpgradeLimit?: number;
}
interface ItemDisplayNames {
horse?: string | null;
weapon?: string | null;
book?: string | null;
item?: string | null;
}
interface GeneralInfo {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number;
officerLevel: number;
officerLevelText: string;
@@ -35,17 +47,36 @@ interface GeneralInfo {
experience: number;
dedication: number;
age?: number;
turnTime?: string;
turnTime?: string | null;
troopId?: number;
crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression;
itemNames?: ItemDisplayNames;
equipmentNames?: ItemDisplayNames;
}
const props = defineProps<{
general: GeneralInfo | null;
loading: boolean;
}>();
const props = withDefaults(
defineProps<{
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 general = props.general;
@@ -72,137 +103,320 @@ const statRows = computed(() => {
const experiencePercent = computed(() =>
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>
<template>
<div class="general-card">
<div v-if="props.loading">
<div class="general-card" data-general-basic-card>
<div v-if="props.loading" class="general-loading">
<SkeletonLines :lines="5" />
</div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body">
<div class="general-title">
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }} ·
다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<template v-else>
<div class="general-basic-grid general-body">
<span
class="general-image general-icon"
role="img"
: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">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
<div class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</div>
<strong class="stat-value">
<span>{{ stat.value }}</span>
<span class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</span>
</strong>
</template>
</div>
<div class="legacy-grid">
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
><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>
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
<div class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<div class="bar-cell" data-experience-progress>
<span
class="general-image general-crew-type-icon"
role="img"
: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
:percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/>
</div>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
</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>
<slot name="details" />
</template>
</div>
</template>
<style scoped>
.general-title {
.general-card {
box-sizing: border-box;
height: 20px;
min-height: 20px;
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;
width: 100%;
min-width: 0;
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;
}
.cell-label,
.legacy-grid > span {
background: rgb(20 75 42 / 70%);
.general-basic-grid > strong {
font-weight: 500;
text-align: center;
}
.stat-progress-grid > strong,
.legacy-grid > strong {
text-align: right;
font-weight: 400;
.cell-label {
background-color: rgb(20 75 42 / 70%);
}
.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;
align-content: center;
padding: 0 1px;
}
.legacy-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 21px;
font-size: 12px;
.general-crew-type-icon {
grid-column: 1;
grid-row: 4 / 7;
}
.experience-row {
display: grid;
box-sizing: border-box;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
height: 20px;
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
.level-label {
grid-column: 1;
grid-row: 7;
}
.experience-row > * {
display: grid;
align-content: center;
box-sizing: border-box;
border-right: 1px solid #666;
padding: 1px 4px;
text-align: center;
.level-value {
grid-column: 2;
grid-row: 7;
}
.experience-bar {
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 {
@@ -1,5 +1,9 @@
<script setup lang="ts">
defineProps<{
import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
const props = defineProps<{
tournamentStage: number;
status: {
onlineUserCount: number;
onlineNations: string;
@@ -13,15 +17,24 @@ defineProps<{
} | null;
} | null;
}>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
</script>
<template>
<section class="front-status" aria-label="접속 현황과 국가 방침">
<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 class="activity-status" aria-label="설문과 토너먼트 진행 현황">
<div class="status-row tournament-status">
<RouterLink to="/tournament">
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
</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 class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
<div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div>
@@ -71,19 +84,28 @@ defineProps<{
margin: 0;
}
.vote-status {
width: 33.333333%;
.activity-status {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 66.666667%;
margin-left: auto;
}
.activity-status .status-row {
padding-right: 0;
padding-left: 0;
text-align: center;
}
.vote-status a {
.activity-status a {
color: #fff;
text-decoration: gray underline;
}
.tournament-label {
color: #ffc107;
}
.vote-label {
color: cyan;
}
@@ -93,8 +115,8 @@ defineProps<{
}
@media (max-width: 991px) {
.vote-status {
width: 50%;
.activity-status {
width: 100%;
}
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
interface MapCityView {
id: number;
name: string;
@@ -20,6 +21,7 @@ const props = defineProps<{
city: MapCityView;
showName: boolean;
mapScale: number;
selectOnly?: boolean;
}>();
const emit = defineEmits<{
@@ -31,12 +33,15 @@ const emit = defineEmits<{
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
const stateSize = computed(() => 8 * props.mapScale);
const stateOffset = computed(() => -6 * props.mapScale);
const selectCity = () => emit('select', props.city.id);
</script>
<template>
<RouterLink
<component
:is="props.selectOnly ? 'button' : RouterLink"
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="[
`state-${props.city.stateClass}`,
{ 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` }"
@mouseenter="emit('hover', props.city.id)"
@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` }">
<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>
</RouterLink>
</component>
</template>
<style scoped>
@@ -76,6 +81,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
color: rgba(232, 221, 196, 0.8);
cursor: pointer;
text-decoration: none;
padding: 0;
border: 0;
background: transparent;
}
.city-dot {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
interface MapCityView {
@@ -46,6 +47,7 @@ const props = defineProps<{
imageBaseUrl: string;
themeName: string;
mapScale: number;
selectOnly?: boolean;
}>();
const emit = defineEmits<{
@@ -141,6 +143,8 @@ const capitalIconStyle = computed(() => ({
height: `${10 * props.mapScale}px`,
}));
const selectCity = () => emit('select', props.city.id);
const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
@@ -149,14 +153,16 @@ const cityStateStyle = computed(() => ({
</script>
<template>
<RouterLink
<component
:is="props.selectOnly ? 'button' : RouterLink"
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 }]"
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
@click.stop="selectCity"
>
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
<div class="city-img" :style="cityIconStyle">
@@ -173,7 +179,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" />
</div>
</RouterLink>
</component>
</template>
<style scoped>
@@ -184,6 +190,9 @@ const cityStateStyle = computed(() => ({
color: #fff;
cursor: auto;
text-decoration: none;
padding: 0;
border: 0;
background: transparent;
}
.city-bg {
@@ -67,6 +67,13 @@ const props = defineProps<{
mapData: MapSummary | null;
mapLayout: MapLayout | null;
loading: boolean;
selectedCityId?: number | null;
detailMode?: boolean;
fitContainer?: boolean;
}>();
const emit = defineEmits<{
(event: 'select-city', cityId: number): void;
}>();
const BASE_MAP_WIDTH = 700;
@@ -75,7 +82,12 @@ const SMALL_MAP_SCALE = 5 / 7;
const isWide = useMediaQuery('(min-width: 1024px)');
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 mapBody = ref<HTMLElement | null>(null);
@@ -140,15 +152,20 @@ const dynamicCityById = computed(() => {
});
const mapScale = computed(() => {
if (isWide.value) {
if (isWide.value && !props.fitContainer) {
return 1;
}
if (mapBodyWidth.value <= 0) {
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 mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
@@ -185,7 +202,7 @@ const cityViews = computed<CityView[]>(() => {
y,
isCapital: nation?.capitalCityId === 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(() =>
detailMode.value
effectiveDetailMode.value
? {
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
}
@@ -266,7 +283,7 @@ const titleBandStyle = computed(() =>
);
const titleTextStyle = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
color: titleColor.value,
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
@@ -327,7 +344,7 @@ const mapRoadStyle = computed(() => ({
}));
const detailProps = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
imageBaseUrl: assetBaseUrl.value,
themeName: mapTheme.value,
@@ -365,7 +382,10 @@ const setHoveredCity = (cityId: number | null) => {
};
const selectCity = (cityId: number) => {
mapStore.setSelectedCity(cityId);
emit('select-city', cityId);
if (props.selectedCityId === undefined) {
mapStore.setSelectedCity(cityId);
}
};
</script>
@@ -394,12 +414,13 @@ const selectCity = (cityId: number) => {
<div class="map-layer map-bglayer2" />
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
<component
:is="detailMode ? MapCityDetail : MapCityBasic"
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
v-for="city in cityViews"
:key="city.id"
:city="city"
:map-scale="mapScale"
:show-name="showCityName"
:select-only="props.selectedCityId !== undefined"
v-bind="detailProps"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import GeneralIdentity from '../ui/GeneralIdentity.vue';
import {
buildTournamentBracket,
type TournamentBracketMatch,
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
:class="{ advanced: bracket.champion.advanced }"
: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>
</div>
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="22"
/>
</span>
</div>
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="20"
/>
</span>
</div>
<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}`"
class="mobile-bracket-name"
: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>
</template>
</div>
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
white-space: nowrap;
}
.bracket-canvas {
width: 2000px;
min-width: 2000px;
width: 100%;
min-width: 1000px;
max-width: 1200px;
margin: 0 auto;
}
.mobile-bracket {
position: relative;
display: none;
width: 390px;
width: 100%;
max-width: 390px;
height: 544px;
margin: 0 auto;
}
.mobile-bracket svg {
position: absolute;
inset: 0;
width: 390px;
width: 100%;
height: 544px;
}
.mobile-connector {
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
.mobile-bracket-name {
position: absolute;
z-index: 1;
width: 64px;
width: clamp(58px, 18vw, 72px);
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid #555;
background: rgb(58 33 24 / 92%);
color: #fff;
font-size: 12px;
line-height: 22px;
min-height: 26px;
padding: 2px;
font-size: 11px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
}
.bracket-name {
overflow: hidden;
padding: 0 3px;
padding: 2px 3px;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
}
@media (max-width: 800px) {
.tournament-bracket {
width: 100vw;
max-width: 100vw;
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.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(() =>
[
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
<template>
<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">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
/>
</template>
</div>
<div class="experience-row">
<div v-if="props.showPrimary" class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression.experienceLevel }}</strong>
<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 => {
if (
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);
export const formatSeoulHourMinute = (value: string | Date): string =>
formatServerDateTime(value, { format: 'hourMinute' });
@@ -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 {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
}
export interface TournamentBracketMatch {
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
export interface TournamentBracketSlot {
id: number | null;
name: string;
picture: string | null;
imageServer: number;
advanced: boolean;
}
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
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 = (
participants: TournamentBracketParticipant[],
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
winnerId?: number
): TournamentBracketModel => {
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
const nameOf = (id: number | null): string =>
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`);
const participantOf = (id: number | null): TournamentBracketParticipant | null =>
id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
const roundMatches = matches
.filter((match) => match.stage === stage)
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
[match.attackerId, match.defenderId].map((id) => ({
id,
name: nameOf(id),
advanced: match.winnerId === id,
}))
[match.attackerId, match.defenderId].map((id) => {
const participant = participantOf(id);
return {
id,
name: participant?.name ?? '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
advanced: match.winnerId === id,
};
})
);
while (slots.length < slotCount) {
slots.push(emptySlot());
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
return {
champion: {
id: resolvedWinnerId,
name: nameOf(resolvedWinnerId),
name: participantOf(resolvedWinnerId)?.name ?? '-',
picture: participantOf(resolvedWinnerId)?.picture ?? null,
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
advanced: resolvedWinnerId !== null,
},
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">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue';
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 =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.slice(5, showSecond ? 19 : 16);
}
const parts = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
...(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')}` : ''}`;
return formatServerDateTime(value, {
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
fallback: '-',
});
};
const buyRice = computed(() =>
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number];
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
const formatGeneralLabel = (general: GeneralEntry): string => {
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') {
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`;
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
}
if (orderBy.value === 'warnum') {
return `${name} (${general.warnum}회)`;
@@ -136,8 +137,6 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
return `${name} (${time})`;
};
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
let logRequestId = 0;
const loadLogs = async (generalId: number) => {
@@ -156,7 +155,10 @@ const loadLogs = async (generalId: number) => {
}
for (const response of responses) {
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 {
id: entry.id,
html: formatLog(`${entry.text}${eventTime}`),
@@ -266,49 +268,36 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
</div>
<span
class="battle-general-portrait"
role="img"
:aria-label="`${selectedGeneral.name} 초상`"
:style="{ backgroundImage: `url(${generalImageUrl(selectedGeneral)})` }"
/>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
<div 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>
<GeneralBasicCard
class="battle-general-card"
:general="selectedGeneral"
:loading="loading"
:nation-color="data?.nation.color"
>
<template v-if="selectedGeneral" #details>
<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.warnum }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
><strong>{{ selectedGeneral.battleStats.fire }}</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.recentWar || '-' }}</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template>
</GeneralBasicCard>
<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.warnum }}</div>
</div>
@@ -375,39 +364,7 @@ onMounted(() => {
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 {
clear: both;
display: grid;
grid-template-columns: repeat(6, 1fr);
}
@@ -433,24 +390,6 @@ onMounted(() => {
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 {
display: contents;
}
+199 -75
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -12,6 +14,7 @@ const loading = ref(false);
const error = ref<string | null>(null);
const message = ref<string | null>(null);
const amounts = ref<Record<number, number>>({});
const activeRankingPrefix = ref('tt');
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
@@ -57,7 +60,13 @@ const final16Ids = computed(() =>
const candidates = computed(() =>
Array.from({ length: 16 }, (_, index) => {
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);
@@ -68,7 +77,9 @@ const ratio = (id: number) => {
const amount = totals?.[id] ?? 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 myTotals = summary.value?.myTotals as Record<number, number> | undefined;
const current = myTotals?.[id] ?? 0;
@@ -129,58 +140,43 @@ const placeBet = async (targetId: number) => {
:bet-totals="betTotals"
:total-bet="totalAmount"
:show-legend="false"
force-desktop
/>
<section class="candidate-table bg0">
<div class="candidate-row names">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
<div class="candidate-grid">
<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 class="candidate-row ratios">
<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>
<p class="candidate-help">
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
<span class="return-color">적중시 환수금</span><br />
<span class="ratio-color">( 베팅후 500 이하일땐 베팅이 불가능합니다. )</span>
@@ -201,8 +197,26 @@ const placeBet = async (targetId: number) => {
<section class="ranking-placeholder bg0">
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
</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">
<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>
<tr>
<th colspan="9">{{ section.title }}</th>
@@ -222,7 +236,14 @@ const placeBet = async (targetId: number) => {
<tbody>
<tr v-for="entry in section.entries" :key="entry.generalId">
<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.games }}</td>
<td>{{ entry.win }}</td>
@@ -248,8 +269,7 @@ const placeBet = async (targetId: number) => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
</footer>
</main>
@@ -257,9 +277,10 @@ const placeBet = async (targetId: number) => {
<style scoped>
.betting-page {
width: 1125px;
height: 1346px;
overflow: hidden;
width: 100%;
max-width: 1200px;
min-width: 0;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -268,8 +289,8 @@ const placeBet = async (targetId: number) => {
text-align: center;
}
.betting-bracket :deep(.bracket-canvas) {
width: 1125px;
min-width: 1125px;
width: 100%;
min-width: 1000px;
}
.betting-bracket :deep(.bracket-round),
.betting-bracket :deep(.connector-row) {
@@ -351,20 +372,34 @@ const placeBet = async (targetId: number) => {
}
.candidate-table {
border: 1px solid gray;
padding: 10px 0;
font-size: 10px;
padding: 10px;
font-size: 12px;
}
.candidate-row {
.candidate-grid {
display: grid;
grid-template-columns: repeat(16, 70px);
align-items: center;
min-height: 10px;
line-height: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.names {
min-height: 14px;
.candidate-card {
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 {
color: skyblue;
}
@@ -376,7 +411,7 @@ const placeBet = async (targetId: number) => {
color: orange;
}
select,
.buttons button {
.candidate-actions button {
width: 100%;
min-height: 27px;
padding: 2px 1px;
@@ -410,7 +445,7 @@ select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.candidate-table p {
.candidate-help {
min-height: 20px;
margin: 8px 0 0;
font-size: 18px;
@@ -429,11 +464,13 @@ select:disabled {
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(4, 280px);
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
}
.ranking-table {
width: 280px;
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 12px;
@@ -441,7 +478,7 @@ select:disabled {
}
.ranking-table th,
.ranking-table td {
height: 14px;
height: 28px;
padding: 1px;
border: 1px solid #555;
}
@@ -455,12 +492,20 @@ select:disabled {
.ranking-table .bg1 {
background: #213b52;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
max-width: 80px;
width: 130px;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-general {
text-align: left;
}
.ranking-tabs {
display: none;
}
.guide {
padding: 10px;
text-align: left;
@@ -468,4 +513,83 @@ select:disabled {
.error {
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>
+13 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
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 =>
resolveGeneralIconUrl({
@@ -160,7 +161,14 @@ onMounted(() => {
</div>
<div class="article-submit-row">
<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>
</section>
@@ -244,7 +252,9 @@ onMounted(() => {
padding: 8px;
color: #000;
background: #fff;
font: 16px/normal 'Times New Roman', serif;
font:
16px/normal 'Times New Roman',
serif;
}
.legacy-board-page {
@@ -8,7 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
import { trpc } from '../utils/trpc';
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 = {
index: number;
@@ -54,6 +54,10 @@ const chiefApi = trpc as unknown as {
query: (input: { generalId: number }) => Promise<CommandTable>;
};
};
world: {
getMap: { query: () => Promise<CommandMapData> };
getMapLayout: { query: () => Promise<CommandMapLayout> };
};
};
type TurnRow = {
@@ -71,6 +75,8 @@ const commandLoading = ref(false);
const error = ref<string | null>(null);
const data = ref<ChiefCenterResponse | 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 router = useRouter();
@@ -109,7 +115,14 @@ const loadCommandTable = async (generalId: number) => {
}
commandLoading.value = true;
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) {
error.value = resolveErrorMessage(err);
} finally {
@@ -317,6 +330,8 @@ const repeatTurns = async (amount: number) => {
:general-id="data.me.id"
:officer-level="selectedChief.officerLevel"
:mobile="true"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns"
@shift="shiftTurns"
@repeat="repeatTurns"
@@ -377,6 +392,8 @@ const repeatTurns = async (amount: number) => {
:loading="commandLoading"
:general-id="data.me.id"
:officer-level="chief.officerLevel"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns"
@shift="shiftTurns"
@repeat="repeatTurns"
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
}
};
const formatArchiveDate = (value: string): string =>
new Intl.DateTimeFormat('sv-SE', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
watch(emperorId, loadDetail);
onMounted(loadDetail);
@@ -67,7 +63,9 @@ onMounted(loadDetail);
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link">
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink>
<RouterLink to="/dynasty"
><button class="native-button" type="button">전체보기</button></RouterLink
>
</span>
</td>
</tr>
@@ -202,7 +200,11 @@ onMounted(loadDetail);
<td colspan="5">
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" />
<div
v-for="(entry, index) in data.emperor.history"
:key="index"
v-html="formatLog(entry)"
/>
</td>
</tr>
</tbody>
@@ -283,14 +285,10 @@ onMounted(loadDetail);
<table class="legacy-table legacy-bg0 footer-table">
<tbody>
<tr>
<td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
<td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
</tr>
</tbody>
</table>
+3 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
if (!turnTimeResult.value) {
return null;
}
const parsed = new Date(turnTimeResult.value);
if (Number.isNaN(parsed.getTime())) {
return turnTimeResult.value;
}
return parsed.toLocaleString();
return formatServerDateTime(turnTimeResult.value);
});
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-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<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>
</div>
<button
+24 -17
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core';
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
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);
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
const time = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(parsed);
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!time) return formatLog(entry.text);
return formatLog(`${entry.text} ${time}`);
};
@@ -148,13 +143,9 @@ watch(
<header class="game-shell__header">
<div>
<h1 class="game-shell__title">
{{ isMobile ? '전장 현황' : lobbyInfo?.scenarioTitle || '전장 현황' }}
{{ lobbyInfo?.scenarioTitle || '전장 현황' }}
</h1>
<p class="game-shell__subtitle">
{{
!isMobile && lobbyInfo?.scenarioTitle ? `${lobbyInfo.scenarioTitle} ${statusLine}` : statusLine
}}
</p>
<p class="game-shell__subtitle">{{ statusLine }}</p>
</div>
<div class="game-shell__actions desktop-action-controls">
<button
@@ -197,7 +188,7 @@ watch(
</div>
<div data-main-target="policy">
<MainFrontStatus :status="frontStatus" />
<MainFrontStatus :status="frontStatus" :tournament-stage="tournamentStage" />
</div>
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
@@ -220,6 +211,8 @@ watch(
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@set-general-turns="reserveGeneralTurns"
@shift-general-turns="shiftGeneralTurns"
@repeat-general-turns="repeatGeneralTurns"
@@ -241,7 +234,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<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 title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
@@ -344,6 +342,8 @@ watch(
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@set-general-turns="reserveGeneralTurns"
@shift-general-turns="shiftGeneralTurns"
@repeat-general-turns="repeatGeneralTurns"
@@ -356,7 +356,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<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>
<MainNationMenu
class="nation-menu-middle"
@@ -605,12 +610,14 @@ button {
.layout-desktop > [data-main-target='nation'] {
grid-column: 1 / 6;
grid-row: 3;
align-self: stretch;
min-height: 193px;
}
.layout-desktop > [data-main-target='general'] {
grid-column: 6 / 11;
grid-row: 3;
align-self: stretch;
min-height: 193px;
}
+97 -130
View File
@@ -7,6 +7,7 @@ import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
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 selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
@@ -360,7 +362,7 @@ onMounted(() => {
</script>
<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">
<span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
@@ -374,91 +376,43 @@ onMounted(() => {
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<span
class="portrait-image"
role="img"
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }"
></span>
<strong>{{ data.general.name }}</strong>
</div>
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
<GeneralBasicCard
class="general-table"
:general="data?.general ?? null"
:loading="loading"
:nation-color="data?.nation?.color"
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
:penalty-text="penalties.length || '-'"
>
<template v-if="data" #details>
<div 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.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>
<dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
<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>
</template>
</GeneralBasicCard>
</div>
<div class="settings-column">
@@ -541,6 +495,16 @@ onMounted(() => {
<span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</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="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
@@ -675,8 +639,7 @@ onMounted(() => {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
height: 1257.5px;
min-width: 0;
min-height: 0;
margin: 0 auto;
padding: 0;
@@ -786,28 +749,6 @@ button:disabled {
.sky {
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 {
display: none;
}
@@ -824,23 +765,6 @@ button:disabled {
overflow: hidden;
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 {
padding: 10px 18px;
}
@@ -942,6 +866,21 @@ dt {
gap: 6px;
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 {
display: flex;
align-items: center;
@@ -949,16 +888,44 @@ dt {
}
@media (max-width: 991px) {
.legacy-page {
width: 500px;
height: 1798.34px;
width: 100%;
max-width: 100%;
}
.my-page-mobile-scroll-spacer {
display: block;
height: 100px;
display: none;
}
.top-grid,
.log-grid {
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>
+661 -171
View File
@@ -1,28 +1,202 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useMediaQuery } from '@vueuse/core';
import { formatOfficerLevelText } from '../utils/nationFormat';
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';
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
type 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 router = useRouter();
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(1);
const viewMenuOpen = ref(false);
const columnMenuOpen = ref(false);
const isNarrow = useMediaQuery('(max-width: 1000px)');
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55));
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42));
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16));
const nameFilter = ref('');
const officerFilter = ref('');
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const currentSetting = ref<NationGeneralSettingKey>([true, 'normal']);
const displaySettings = ref(new Map<string, NationGeneralDisplaySetting>());
const columnState = ref<NationGeneralColumnState[]>([]);
const groupState = ref<Record<NationGeneralGroupId, boolean>>({
expDedLv: true,
stat: true,
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 () => {
loading.value = true;
error.value = '';
@@ -34,36 +208,266 @@ const load = async () => {
loading.value = false;
}
};
const generals = computed(() =>
[...(data.value?.generals ?? [])]
.filter(
(general) =>
general.name.includes(nameFilter.value.trim()) &&
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes(
officerFilter.value.trim()
)
)
.sort((a, b) => {
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 (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
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;
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 ?? '');
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
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 stateById = computed(() => new Map(columnState.value.map((column) => [column.colId, column])));
const isColumnVisible = (columnId: NationGeneralColumnId): boolean => !(stateById.value.get(columnId)?.hide ?? true);
const activeColumnIds = computed<NationGeneralColumnId[]>(() => {
const active: NationGeneralColumnId[] = [];
for (const item of layout) {
if (item.type === 'column') {
if (isColumnVisible(item.columnId)) active.push(item.columnId);
continue;
}
if (groupState.value[item.groupId]) {
active.push(...item.children.filter(isColumnVisible));
} else if (isColumnVisible(item.summaryId)) {
active.push(item.summaryId);
}
}
return active;
});
const activeColumns = computed(() =>
activeColumnIds.value.map((columnId) => columnById.get(columnId)).filter((column) => column !== undefined)
);
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 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);
</script>
@@ -80,40 +484,68 @@ onMounted(load);
<button
class="top-button mode-button"
:aria-expanded="viewMenuOpen"
@click="viewMenuOpen = !viewMenuOpen"
@click="
viewMenuOpen = !viewMenuOpen;
columnMenuOpen = false;
"
>
보기 모드
</button>
<span v-if="viewMenuOpen" class="dropdown-menu">
<button
@click="
sort = 1;
viewMenuOpen = false;
"
>
기본
</button>
<button
@click="
sort = 4;
viewMenuOpen = false;
"
>
전투
</button>
<span v-if="viewMenuOpen" class="dropdown-menu view-mode-list">
<button @click="setDisplayMode('normal')">기본</button>
<button @click="setDisplayMode('war')">전투</button>
<span class="menu-divider"></span>
<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 class="saved-setting-name" @click="applyDisplaySetting([false, key], setting)">
{{ key }}
</button>
<button
class="saved-setting-delete"
:aria-label="`${key} 설정 삭제`"
@click.stop="deleteDisplaySetting(key)"
>
삭제
</button>
</span>
</template>
</span>
</span>
<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>
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
<label
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']"
:key="label"
>
<input type="checkbox" checked /> {{ label }}
</label>
<template v-for="item in layout" :key="item.type === 'column' ? item.columnId : item.groupId">
<label v-if="item.type === 'column' && item.columnId !== 'name'">
<input
type="checkbox"
:checked="isColumnVisible(item.columnId)"
@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>
@@ -121,100 +553,99 @@ onMounted(load);
<p v-if="error" class="state error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="grid-shell">
<table id="nation-general-list">
<table id="nation-general-list" :style="{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }">
<colgroup>
<col
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
:key="index"
:style="{ width: `${width}px` }"
/>
<col v-for="column in activeColumns" :key="column.id" :style="{ width: `${column.width}px` }" />
</colgroup>
<thead>
<tr class="group-head">
<th colspan="2"></th>
<th></th>
<th>명성/계급&#x3000;</th>
<th colspan="3">능력치&#x3000;</th>
<th colspan="2">자금&#x3000;</th>
<th colspan="2">특성&#x3000;</th>
<th>연도&#x3000;</th>
<th>기타&#x3000;</th>
<th v-for="segment in headerSegments" :key="segment.key" :colspan="segment.colspan">
<button
v-if="segment.groupId"
class="group-toggle"
:aria-expanded="segment.open"
:aria-label="`${segment.label} ${segment.open ? '접기' : '펼치기'}`"
@click="toggleGroup(segment.groupId)"
>
{{ segment.label }}&#x3000;{{ segment.open ? '' : '' }}
</button>
</th>
</tr>
<tr>
<th>아이콘</th>
<th>장수명</th>
<th>관직</th>
<th>계급</th>
<th>명성</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">벌점 </th>
<th v-for="column in activeColumns" :key="column.id">
<button
class="sort-button"
:class="{ sortable: column.sortable }"
:disabled="!column.sortable"
:aria-label="column.sortable ? `${column.label} 정렬` : undefined"
@click="sortColumn(column.id, $event)"
>
{{ column.label }}
<span class="sort-indicator">{{ sortIndicator(column.id) }}</span>
</button>
</th>
</tr>
<tr class="filter-head">
<th></th>
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span></span></th>
<th><input v-model="officerFilter" aria-label="관직 필터" /><span></span></th>
<th><input aria-label="계급 필터" /><span></span></th>
<th><input aria-label="명성 필터" /><span></span></th>
<th><input aria-label="통솔 필터" /><span></span></th>
<th><input aria-label="무력 필터" /><span></span></th>
<th><input aria-label="지력 필터" /><span></span></th>
<th><input aria-label=" 필터" /><span></span></th>
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span></span></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span></span></th>
<th v-for="column in activeColumns" :key="column.id">
<template v-if="column.searchable">
<input
v-model="filters[column.id]"
type="search"
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
:aria-label="`${column.label} 필터`"
:placeholder="column.searchable === 'number' ? '=, >, <' : ''"
/>
<span></span>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(general, index) in generals" :key="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>
<tr v-for="(general, index) in generals" :key="general.id" :data-general-id="general.id">
<td
v-if="!isNarrow"
:title="
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
"
v-for="column in activeColumns"
:key="column.id"
: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) }}
</td>
<td v-if="!isNarrow">
{{ general.refreshScoreTotal }}<br />({{ general.belong ? '자주' : '안함' }})
<template v-if="column.id === 'icon'">
<img v-if="index < 16" :src="iconUrl(general)" alt="" />
<span
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>
</tr>
<tr v-if="!generals.length" class="empty-row">
<td :colspan="activeColumns.length">검색 결과가 없습니다.</td>
</tr>
</tbody>
</table>
<div class="ag-compat-controls" aria-hidden="true">
<button
v-for="index in compatButtonCount"
:key="`button-${index}`"
type="button"
tabindex="-1"
></button>
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
<button v-for="index in 55" :key="`button-${index}`" type="button" tabindex="-1"></button>
<input v-for="index in 42" :key="`input-${index}`" tabindex="-1" />
</div>
</div>
</main>
@@ -222,9 +653,8 @@ onMounted(load);
<style scoped>
.general-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
width: 1000px;
min-width: 1000px;
height: 100vh;
margin: 0 auto;
font: 14px/21px var(--sammo-font-sans);
@@ -242,7 +672,6 @@ onMounted(load);
justify-content: center;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
/* Ref's `.back_bar` has no bottom rule; the grid below draws its own. */
font-size: 14px;
}
.top-bar strong {
@@ -262,20 +691,19 @@ onMounted(load);
.right-actions {
right: 0;
}
/* Ref Lumen primary: the bottom edge carries the pressed-state movement. */
.top-button {
display: inline-flex;
width: 89px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-right: 1px solid #151515;
border-radius: 3px;
color: #fff;
width: 89px;
justify-content: center;
padding: 0;
font-weight: 700;
font-size: 14px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
@@ -289,14 +717,11 @@ onMounted(load);
background: #375a7f;
border-bottom: 0 solid #325172;
}
/* Ref Lumen primary: a 3px bottom edge appears on hover and stays while open. */
.mode-button:hover,
.mode-button[aria-expanded='true'],
.mode-button:active {
border-bottom-width: 3px;
}
.mode-button,
.columns-button {
width: 90px;
@@ -312,16 +737,22 @@ onMounted(load);
}
.dropdown-menu {
position: absolute;
z-index: 5;
z-index: 20;
top: 32px;
right: 0;
width: 150px;
width: 170px;
max-height: calc(100vh - 40px);
padding: 4px;
background: #252a2c;
overflow-y: auto;
border: 1px solid #596164;
background: #252a2c;
}
.view-mode-list {
width: 180px;
}
.dropdown-menu button,
.dropdown-menu label {
.dropdown-menu label,
.column-group-label {
display: block;
width: 100%;
padding: 5px;
@@ -330,6 +761,30 @@ onMounted(load);
background: transparent;
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 {
width: 100%;
height: calc(100vh - 32px);
@@ -340,23 +795,21 @@ onMounted(load);
cursor: default;
}
table {
width: 1000px;
min-width: 1000px;
border-collapse: collapse;
border-collapse: separate;
table-layout: fixed;
background: #293033;
color: #f5f5f5;
font-size: 14px;
line-height: normal;
color: #f5f5f5;
cursor: default;
}
th,
td {
padding: 0 4px;
overflow: hidden;
border-right: 1px solid #40484b;
border-bottom: 1px solid #4a5255;
padding: 0 4px;
text-align: center;
overflow: hidden;
}
th {
height: 32px;
@@ -369,6 +822,36 @@ th {
height: 32px;
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 {
height: 32px;
padding: 3px 4px;
@@ -380,6 +863,14 @@ th {
background: #252a2c;
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 {
margin-left: 4px;
color: #a5b5bf;
@@ -392,7 +883,7 @@ tbody tr:hover {
background: #343c3f;
}
td {
white-space: nowrap;
white-space: pre-line;
}
.icon-cell {
padding: 0 4px;
@@ -416,21 +907,16 @@ td {
display: none;
}
.name-cell {
text-align: left;
color: skyblue;
text-align: left;
}
th:nth-child(9),
td:nth-child(9),
th:nth-child(10),
td:nth-child(10) {
.numeric-cell {
text-align: right;
}
.state {
margin: 40px;
}
.npc-0 {
color: skyblue;
}
.npc-0,
.npc-1 {
color: skyblue;
}
@@ -443,6 +929,10 @@ td:nth-child(10) {
.error {
color: #ff7373;
}
.empty-row td {
height: 68px;
text-align: center;
}
@media (max-width: 1000px) {
.general-page {
margin: 0;
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
@@ -147,7 +148,7 @@ onMounted(load);
>
</td>
<td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(14, 19) }}</td>
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr>
</tbody>
</table>
@@ -159,8 +160,8 @@ onMounted(load);
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
</tr>
+3 -9
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
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 formatStartDate = (value: string): string => value.slice(0, 10);
const formatStartDate = (value: string): string => formatServerDateTime(value, { format: 'date' });
const formatCommentDate = (value: string): string => {
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 formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const voteColor = (index: number): string =>
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
+193 -56
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc';
import { resolveTournamentStageName } from '../utils/tournamentStatus';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -12,22 +15,11 @@ const loading = ref(false);
const error = ref<string | null>(null);
const actionMessage = ref<string | null>(null);
const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
];
const typeStatNames = ['종합', '통솔', '무력', '지력'];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
@@ -62,7 +54,9 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
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 isParticipant = computed(() =>
(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))
)
);
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 state = snapshot.value?.state;
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="state-row bg0">
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }}, 개막시간 {{ openingTime }}, 경기당
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }})
</section>
<section class="section-title bg2">16 승자전</section>
@@ -164,7 +178,6 @@ const start = async () => {
:winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals"
:total-bet="totalBet"
force-desktop
/>
<section v-if="currentMatch" class="fight bg0">
@@ -173,18 +186,35 @@ const start = async () => {
</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">
<table v-for="(group, groupIndex) in groups" :key="groupIndex">
<table
v-for="(group, groupIndex) in groups"
:key="groupIndex"
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
>
<caption>
{{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<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>
@@ -196,26 +226,21 @@ const start = async () => {
<tbody>
<tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td>
<td>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) +
(group[rowIndex - 1]!.draw ?? 0) +
(group[rowIndex - 1]!.lose ?? 0)
: ''
}}
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
:icon-size="24"
/>
</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>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
: ''
}}
</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
@@ -223,18 +248,35 @@ const start = async () => {
</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">
<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>
{{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1]
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<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>
@@ -246,14 +288,22 @@ const start = async () => {
<tbody>
<tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
:icon-size="24"
/>
</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>
</tbody>
</table>
@@ -287,8 +337,7 @@ const start = async () => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
</footer>
@@ -302,9 +351,10 @@ const start = async () => {
<style scoped>
.legacy-page {
width: 2009px;
height: 1059px;
overflow: hidden;
width: 100%;
max-width: 1200px;
min-width: 0;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -431,13 +481,15 @@ button:focus-visible {
}
.group-grid {
display: grid;
grid-template-columns: repeat(8, 250px);
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
}
table {
width: 250px;
width: 100%;
border-collapse: collapse;
table-layout: auto;
table-layout: fixed;
}
caption {
padding: 3px;
@@ -450,14 +502,99 @@ th {
}
th,
td {
height: 17px;
height: 30px;
border: 1px solid #555;
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 {
text-align: left;
}
.error-row {
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>
+4 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
@@ -204,8 +202,8 @@ onMounted(() => {
</tr>
<tr>
<td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
</tr>
+2 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
const formatTurn = (turnTime: string | null): string => {
if (!turnTime) {
return '--:--';
}
return turnTime.slice(14, 19);
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
};
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';
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 = [
...Array.from({ length: 8 }, (_, index) => ({
id: index + 1,
@@ -37,6 +42,8 @@ void describe('tournament bracket', () => {
const bracket = buildTournamentBracket(participants, matches, 1);
assert.equal(bracket.champion.name, '장수1');
assert.equal(bracket.champion.picture, '1.jpg');
assert.equal(bracket.top16.slots[1]?.imageServer, 1);
assert.deepEqual(
bracket.top16.slots.map((slot) => slot.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', () => {
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.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[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;
}
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.');
}
} catch {
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
.map((profile) => ({
profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
meta: {
...(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);
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;
gitRef = profile.buildCommitSha?.trim();
if (!gitRef) {
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
upsert: profileAdminProcedure
.input(
z.object({
profile: z.string().min(1).max(32),
scenario: z.string().min(1).max(64),
profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
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),
status: zProfileStatus.optional(),
preopenAt: z.string().datetime().optional(),
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
const status = input.status ?? 'STOPPED';
return ctx.profiles.upsertProfile({
profile: input.profile,
instanceKey: input.instanceKey,
currentScenario: input.currentScenario,
scenario: input.scenario,
apiPort: input.apiPort,
status,
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
export type LobbyProfileStatus = {
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
status: GatewayProfileStatus;
apiPort: number;
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
return {
profileName: row.profileName,
profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario,
status: row.status,
apiPort: row.apiPort,
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
...baseEnv,
GAME_API_ROLE: 'server',
PROFILE: profile.profile,
SCENARIO: profile.scenario,
SCENARIO: profile.currentScenario ?? 'default',
GAME_PROFILE_NAME: profile.profileName,
GAME_API_PORT: String(profile.apiPort),
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
GAME_ENGINE_ROLE: 'turn-daemon',
TURN_PROFILE: profile.profile,
PROFILE: profile.profile,
SCENARIO: profile.scenario,
SCENARIO: profile.currentScenario ?? 'default',
TURN_PROFILE_NAME: profile.profileName,
};
return {
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} = parseInstallOptions(action);
const tickOverride =
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario);
if (!scenarioId) {
const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
if (scenarioId === null) {
return { status: 'FAILED', detail: 'scenarioId is missing' };
}
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const publishedProfile = await updateClaimedProfile(
{
scenario: String(scenarioId),
currentScenario: String(scenarioId),
status: desiredStatus,
buildStatus: 'SUCCEEDED',
buildWorkspace: workspace.root,
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
completedAt,
error: null,
});
if (String(scenarioId) !== profile.scenario) {
await this.repository.updateScenario(profile.profileName, String(scenarioId));
if (String(scenarioId) !== profile.currentScenario) {
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
}
return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
releasePrepared = true;
const builtProfile = publishedProfile ?? {
...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId),
status: desiredStatus,
buildWorkspace: workspace.root,
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
meta: Record<string, unknown>;
}> {
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 meta: Record<string, unknown> = {};
const connector = createGamePostgresConnector({ url: databaseUrl });
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
export interface GatewayProfileRecord {
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
apiPort: number;
status: GatewayProfileStatus;
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
export interface GatewayProfileUpsertInput {
profile: string;
scenario: string;
instanceKey?: string;
currentScenario?: string | null;
/** @deprecated Accepted while older bootstrap clients are still supported. */
scenario?: string;
apiPort: number;
status?: GatewayProfileStatus;
preopenAt?: string;
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
}
export interface GatewayClaimedProfileUpdate {
scenario?: string;
currentScenario?: string | null;
status?: GatewayProfileStatus;
buildStatus?: GatewayBuildStatus;
buildCommitSha?: string | null;
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
listProfiles(): Promise<GatewayProfileRecord[]>;
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>;
updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
updateStatus(
profileName: string,
status: GatewayProfileStatus,
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
type GatewayProfileRow = {
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
scenario: string;
apiPort: number;
status: GatewayProfileStatus;
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName,
profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario,
apiPort: row.apiPort,
status: row.status,
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
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 => ({
id: row.id,
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
});
return rows.map(mapProfile);
},
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return row ? mapProfile(row) : null;
},
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({
where: { profileName },
create: {
profileName,
profile: input.profile,
scenario: input.scenario,
instanceKey,
currentScenario,
scenario: currentScenario ?? 'default',
apiPort: input.apiPort,
status: input.status ?? 'STOPPED',
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
},
update: {
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
apiPort: input.apiPort,
status: input.status,
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);
},
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({
where: { profileName },
data: {
scenario,
currentScenario: scenario,
scenario: scenario ?? 'default',
},
});
return row ? mapProfile(row) : null;
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return tx.gatewayProfile.update({
where: { profileName },
data: {
scenario: patch.scenario,
currentScenario: patch.currentScenario,
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
status: patch.status,
buildStatus: patch.buildStatus,
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]));
export const compareGatewayProfiles = (
left: { profile: string; scenario: string },
right: { profile: string; scenario: string }
left: { profile: string; instanceKey: string },
right: { profile: string; instanceKey: string }
): number => {
const unknownRank = GATEWAY_PROFILE_ORDER.length;
const profileOrder =
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
const profileNameOrder = left.profile.localeCompare(right.profile);
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);
+5 -1
View File
@@ -85,6 +85,8 @@ const buildCaller = async (
const profile = {
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: options.profileScenario ?? '2',
scenario: options.profileScenario ?? '2',
apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const),
@@ -98,7 +100,7 @@ const buildCaller = async (
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => {
updatedStatuses.push(status);
return { ...profile, status };
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
{
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: '2',
meta: {},
},
]);
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
const profile = {
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
apiPort: 15003,
status: 'RUNNING' as const,
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
listProfiles: async () => [profile],
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async () => profile,
updateBuildStatus: async () => profile,
updateMeta: async () => profile,
+7 -1
View File
@@ -92,6 +92,8 @@ const buildCaller = (
{
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
apiPort: 15003,
status: 'RUNNING' as const,
@@ -103,6 +105,8 @@ const buildCaller = (
{
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
apiPort: 15015,
status: 'RUNNING' as const,
@@ -119,7 +123,7 @@ const buildCaller = (
upsertProfile: async () => {
throw new Error('not used');
},
updateScenario: async () => null,
updateCurrentScenario: async () => null,
updateStatus: async () => null,
updateBuildStatus: async () => null,
updateMeta: async () => null,
@@ -167,6 +171,8 @@ const buildCaller = (
profileRows.map((profile) => ({
profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
scenario: profile.scenario,
status: profile.status,
apiPort: profile.apiPort,
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const profile: GatewayProfileRecord = {
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2',
apiPort: 15003,
status: 'STOPPED',
@@ -58,7 +60,7 @@ const createHarness = (
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => {
statuses.push(status);
return { ...profile, status };
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2',
apiPort: 15003,
status: 'RUNNING',
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
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', () => {
const definitions = buildProcessDefinitions(buildProfile(), {
...processConfig,
@@ -14,6 +14,8 @@ const makeProfile = (
): GatewayProfileRecord => ({
profileName,
profile: profileName.split(':')[0] ?? 'che',
instanceKey: profileName.split(':')[1] ?? 'default',
currentScenario: null,
scenario: profileName.split(':')[1] ?? 'default',
apiPort: 15_003,
status: 'RUNNING',
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
const profile: GatewayProfileRecord = {
profileName: 'che:1010',
profile: 'che',
instanceKey: '1010',
currentScenario: '1010',
scenario: '1010',
apiPort: 15003,
status: 'RUNNING',
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async () => profile,
updateBuildStatus: 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', () => {
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
profile,
scenario: 'default',
instanceKey: 'default',
}));
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 = [
{ profile: 'zeta', scenario: 'default' },
{ profile: 'che', scenario: '20' },
{ profile: 'alpha', scenario: 'default' },
{ profile: 'che', scenario: '10' },
{ profile: 'zeta', instanceKey: 'default' },
{ profile: 'che', instanceKey: '20' },
{ profile: 'alpha', instanceKey: 'default' },
{ profile: 'che', instanceKey: '10' },
];
expect(orderGatewayProfiles(profiles)).toEqual([
{ profile: 'che', scenario: '10' },
{ profile: 'che', scenario: '20' },
{ profile: 'alpha', scenario: 'default' },
{ profile: 'zeta', scenario: 'default' },
{ profile: 'che', instanceKey: '10' },
{ profile: 'che', instanceKey: '20' },
{ profile: 'alpha', instanceKey: 'default' },
{ profile: 'zeta', instanceKey: 'default' },
]);
expect(profiles[0]?.profile).toBe('zeta');
});
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs',
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
gameSchemaHead: '20260803000000_add_logical_game_clock',
});
});
@@ -150,6 +150,8 @@ const installFixture = async (
{
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
meta: {},
},
]);
@@ -160,6 +162,8 @@ const installFixture = async (
{
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
scenario: '1010',
apiPort: 15015,
status: 'RUNNING',
@@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p
await expect(versionTab).toBeFocused();
const tabAndHeaderGeometry = await Promise.all([
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]);
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',
profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
scenario: '1010',
status: 'RUNNING',
buildStatus: 'SUCCEEDED',
@@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
{
profileName: 'hwe:2',
profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
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 scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
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: '사용자 관리' })).toHaveCount(0);
await scopedContext.close();
@@ -55,8 +55,10 @@ type FixtureState = {
};
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
profileName: 'che:2',
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
currentScenario: '2',
scenario: '2',
apiPort: 15003,
status: runtimeRunning ? 'RUNNING' : 'STOPPED',
@@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
activeOperation: null,
runtimeActions: [],
runtime: {
profileName: 'che:2',
profileName: 'che:default',
frontendRunning: runtimeRunning,
apiRunning: runtimeRunning,
daemonRunning: runtimeRunning,
@@ -145,10 +147,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
await route.abort('failed');
return;
}
if (
names.includes('admin.releases.gatewayState') &&
(state.gatewayStateFailuresRemaining ?? 0) > 0
) {
if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) {
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
await route.fulfill({
@@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.profiles.listNavigation') {
return response([
{
profileName: 'che:2',
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
currentScenario: '2',
meta: { korName: '천하서버' },
},
]);
@@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestReset') {
const operation: Operation = {
id: '11111111-1111-4111-8111-111111111111',
profileName: 'che:2',
profileName: 'che:default',
type: 'RESET',
status: 'QUEUED',
sourceMode: 'COMMIT',
@@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestDeploy') {
const operation: Operation = {
id: '66666666-6666-4666-8666-666666666666',
profileName: 'che:2',
profileName: 'che:default',
type: 'DEPLOY',
status: 'QUEUED',
sourceMode: 'BRANCH',
@@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
type === 'START'
? '22222222-2222-4222-8222-222222222222'
: '33333333-3333-4333-8333-333333333333',
profileName: 'che:2',
profileName: 'che:default',
type,
status: 'SUCCEEDED',
payload: {},
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.retry') {
const operation: Operation = {
id: '44444444-4444-4444-8444-444444444444',
profileName: 'che:2',
profileName: 'che:default',
type: 'RESET',
status: 'QUEUED',
sourceMode: 'COMMIT',
@@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await installFixture(page, state);
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).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-help')).toContainText('현재 서버에 배포된 커밋');
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('load-scenarios').click();
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').click();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('RESET');
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-status')).toContainText('SUCCEEDED');
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('0123456789abcdef0123456789abcdef01234567');
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.setViewportSize({ width: 390, height: 844 });
@@ -588,9 +591,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
mobileOperationTableGeometry.scrollerWidth
);
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth
).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth);
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth
);
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth
);
@@ -612,7 +615,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
await installFixture(page, state);
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.getByText('운영 프로필', { exact: true })).toHaveCount(0);
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.getByTestId('operations-table')).toContainText('DEPLOY');
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-status')).toContainText('SUCCEEDED');
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);
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 page.getByText('고급 시나리오 옵션').click();
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: [] };
await installFixture(page, state);
await page.goto('admin/servers/che%3A2');
await page.goto('admin/servers/che%3Adefault');
await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10');
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 page.goto('admin/servers/che%3A2');
await page.goto('admin/servers/che%3Adefault');
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
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 page.goto('admin/servers/che%3A2/version');
await page.goto('admin/servers/che%3Adefault/version');
await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
expect(state.profileNavigationResolved).toBe(false);
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 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('request-reset')).toBeDisabled();
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();
});
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 = {
operations: [],
gatewayOperations: [],
@@ -798,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request
};
await installFixture(page, state);
await page.goto('admin/servers/che%3A2');
await page.goto('admin/servers/che%3Adefault');
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(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);
});
@@ -811,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c
gatewayOperations: [],
runtimeRunning: true,
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);
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-branch')).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.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(
mobileGeometry.viewportWidth
);
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
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: [
{
id: '55555555-5555-4555-8555-555555555555',
profileName: 'che:2',
profileName: 'che:default',
type: 'RESET',
status: 'FAILED',
sourceMode: 'COMMIT',
@@ -1062,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
await installFixture(page, state);
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.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
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[] }>> } };
profiles: {
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 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(() =>
(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 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(() => [
@@ -70,6 +87,7 @@ const navigation = computed(() => [
...profiles.value.map((profile) => ({
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
label: profileLabel(profile),
title: `서버 ID: ${profile.profileName}`,
icon: '└',
exact: false,
visible: true,
@@ -156,6 +174,7 @@ onMounted(async () => {
:class="{ child: item.child }"
:active-class="item.exact ? '' : 'active'"
:exact-active-class="item.exact ? 'active' : ''"
:title="'title' in item ? item.title : undefined"
@click="menuOpen = false"
>
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
@@ -130,7 +131,7 @@ const scheduleDeletion = async (): Promise<void> => {
currentCredential,
});
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('/');
});
};
@@ -411,7 +412,7 @@ onBeforeUnmount(() => {
</tr>
<tr>
<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">
개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
<button
+39 -40
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
@@ -175,6 +176,9 @@ type AdminPublicUser = {
type AdminProfile = {
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
status: string;
apiPort: number;
@@ -435,8 +439,7 @@ const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
const formatRuntimeActionTime = (value: string | null): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
const userLookupValue = ref('');
@@ -630,14 +633,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
}
};
const toLocalInputValue = (value: string): string => {
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 toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
const loadProfiles = async () => {
profilesLoading.value = true;
@@ -795,7 +791,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
const scheduledAt =
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
? new Date(actionState.scheduledAt).toISOString()
? serverDateTimeInputToIso(actionState.scheduledAt)
: undefined;
const reason = actionState?.reason.trim() || undefined;
let runtimeActionId: string | undefined;
@@ -952,7 +948,7 @@ const updateKakaoGrace = async (clear = false) => {
try {
const result = await adminClient.users.updateKakaoGrace.mutate({
userId: userResult.value.id,
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(),
until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
reason,
});
userResult.value = {
@@ -983,7 +979,9 @@ const grantSpecialAccess = async () => {
.map((profile) => profile.trim())
.filter(Boolean),
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
: null,
reason,
});
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
@@ -1071,7 +1069,7 @@ const applyBan = async () => {
}
const reason = requireUserActionReason();
if (!reason) return;
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
const patch = {
bannedUntil: until,
notes: banReason.value.trim() || undefined,
@@ -1150,7 +1148,7 @@ const applyRestriction = async () => {
.filter(Boolean);
const restriction = {
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,
notes: restrictionNotes.value.trim() || undefined,
};
@@ -1213,7 +1211,7 @@ const scheduleDeleteUser = async () => {
reason,
});
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()]);
} catch (error) {
forceDeleteStatus.value = '탈퇴 예약 실패';
@@ -1357,7 +1355,7 @@ onMounted(() => {
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
<span
>{{ user.oauthType }} ·
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span
{{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
>
</span>
<span class="flex flex-wrap gap-1 md:justify-end">
@@ -1436,15 +1434,17 @@ onMounted(() => {
</div>
<div class="text-xs text-zinc-500">
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }}
{{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
</div>
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
</div>
<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 class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</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"
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
@@ -1632,7 +1632,8 @@ onMounted(() => {
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select
@@ -1696,11 +1697,11 @@ onMounted(() => {
</div>
<div>
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
{{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
</div>
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
<div v-if="grant.revokedAt" class="text-red-300">
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
{{ grant.revokedReason }}
</div>
</div>
@@ -1713,7 +1714,8 @@ onMounted(() => {
>
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500">
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다.
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="flex flex-col md:flex-row gap-2">
<input
@@ -1762,11 +1764,7 @@ onMounted(() => {
<td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
<td class="text-center">
{{
policy.graceEndsAt
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
: '-'
}}
{{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
</td>
</tr>
</tbody>
@@ -1778,7 +1776,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'"
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">
<input
v-model="banUntil"
@@ -1817,7 +1815,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'"
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">
<input
v-model="restrictionProfile"
@@ -1936,9 +1934,7 @@ onMounted(() => {
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
@@ -1988,9 +1984,7 @@ onMounted(() => {
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ 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>
<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 class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
+2 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server';
@@ -95,8 +96,7 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
tabButtons?.[nextIndex]?.focus();
};
const formatGraceEndsAt = (value: string | null | undefined): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const encodeLegacyIconPath = (value: string): string =>
value
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
@@ -213,21 +214,11 @@ const sourceHelp = computed(() =>
);
const toIso = (value: string): string | undefined => {
if (!value) {
return undefined;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
return serverDateTimeInputToIso(value);
};
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const formatLogTime = (value: string): string =>
new Date(value).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => {
@@ -959,7 +950,7 @@ onBeforeUnmount(() => {
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400"
>작업 예약
>작업 예약 (서버 시간 UTC+9)
<input
v-model="form.scheduledAt"
type="datetime-local"
@@ -967,7 +958,7 @@ onBeforeUnmount(() => {
/>
</label>
<label class="text-xs text-zinc-400"
>가오픈
>가오픈 (서버 시간 UTC+9)
<input
v-model="form.preopenAt"
type="datetime-local"
@@ -975,7 +966,7 @@ onBeforeUnmount(() => {
/>
</label>
<label class="text-xs text-zinc-400"
>정식 오픈
>정식 오픈 (서버 시간 UTC+9)
<input
v-model="form.openAt"
type="datetime-local"
@@ -1302,10 +1293,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
</div>
<div class="overflow-x-auto">
<table
class="w-full min-w-[1300px] table-fixed text-left text-sm"
data-testid="operations-table"
>
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
<colgroup>
<col style="width: 160px" />
<col style="width: 264px" />
+5 -1
View File
@@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
- `profileName``${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
`che:default``default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
@@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다.
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
- 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults`
+7
View File
@@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
확인합니다.
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`,
`pm_exec_path`, `name`, `NODE_APP_INSTANCE``axm_*` 같은 PM2 내부 값을 자식
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
+1
View File
@@ -1,6 +1,7 @@
export * from './rng.js';
export * from './time/Clock.js';
export * from './time/GameClock.js';
export * from './time/ServerDateTime.js';
export * from './util/BytesLike.js';
export * from './util/convertBytesLikeToArrayBuffer.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 {
profileName String @id @map("profile_name")
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
apiPort Int @map("api_port")
status GatewayProfileStatus
@@ -229,7 +233,7 @@ model GatewayProfile {
operations GatewayOperation[]
runtimeActions GatewayRuntimeAction[]
@@unique([profile, scenario])
@@unique([profile, instanceKey])
@@map("gateway_profile")
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs",
"gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
"gameSchemaHead": "20260803000000_add_logical_game_clock",
"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(page.locator('.online-users')).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 style = getComputedStyle(element);
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
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 {
rect: element.getBoundingClientRect().toJSON(),
fontSize: style.fontSize,
@@ -308,6 +311,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
borderTop: getComputedStyle(onlineRow).borderTop,
padding: getComputedStyle(onlineRow).padding,
},
tournamentRow: tournamentRow.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.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.width).toBeCloseTo(333.33, 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 expect(status).toHaveCSS('width', '500px');
await expect(page.locator('.tournament-status')).toHaveCSS('width', '250px');
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
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();
}