merge: 최신 main을 3분 리셋 지원에 반영한다

This commit is contained in:
2026-08-21 16:41:07 +00:00
38 changed files with 1663 additions and 509 deletions
+3 -2
View File
@@ -175,13 +175,14 @@ export const worldRouter = router({
const turns = generalIds.length const turns = generalIds.length
? await ctx.db.generalTurn.findMany({ ? await ctx.db.generalTurn.findMany({
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
}) })
: []; : [];
const turnMap = new Map<number, string[]>(); const turnMap = new Map<number, Array<{ action: string; args: unknown }>>();
for (const turn of turns) { for (const turn of turns) {
const list = turnMap.get(turn.generalId) ?? []; const list = turnMap.get(turn.generalId) ?? [];
list[turn.turnIdx] = turn.actionCode; list[turn.turnIdx] = { action: turn.actionCode, args: turn.arg };
turnMap.set(turn.generalId, list); turnMap.set(turn.generalId, list);
} }
const nationMap = new Map(nations.map((item) => [item.id, item])); const nationMap = new Map(nations.map((item) => [item.id, item]));
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import { appRouter } from '../src/router.js';
vi.mock('../src/maps/mapLayout.js', () => ({
loadMapLayout: vi.fn(async () => ({
mapName: 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 0, y: 0, path: [] }],
regionMap: { 1: '하북' },
levelMap: { 8: '특' },
})),
}));
vi.mock('@sammo-ts/game-engine/scenario/unitSetLoader.js', () => ({
loadUnitSetDefinitionByName: vi.fn(async () => ({ crewTypes: [{ id: 1, name: '보병' }] })),
}));
const now = new Date('2026-01-01T01:02:00Z');
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'u1',
name: '장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 900,
dedication: 100,
officerLevel: 1,
gold: 1000,
rice: 2000,
crew: 300,
crewTypeId: 1,
train: 90,
atmos: 90,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { defence_train: 80 },
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const token = (): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
sessionId: 'session-1',
user: { id: 'u1', username: 'u1', displayName: '장수', roles: [] },
sanctions: {},
});
const fixture = (authenticated = true) => {
const actor = general();
const npc = general({ id: 2, userId: null, name: 'NPC', npcState: 2 });
const city = {
id: 1,
name: '업',
nationId: 1,
level: 8,
region: 1,
population: 150_000,
populationMax: 620_500,
agriculture: 1_000,
agricultureMax: 12_500,
commerce: 1_000,
commerceMax: 11_300,
security: 1_000,
securityMax: 10_000,
trust: 80,
trade: 100,
defence: 5_000,
defenceMax: 11_700,
wall: 5_000,
wallMax: 12_200,
};
const db = {
general: {
findFirst: vi.fn(async () => actor),
findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
if ('cityId' in where) return [actor, npc];
if ('officerLevel' in where) return [];
if ('nationId' in where) return [actor, npc];
return [];
}),
},
nation: {
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#008000', level: 1, meta: {} })),
findMany: vi.fn(async () => [{ id: 1, name: '위', color: '#008000', level: 1, meta: {} }]),
},
city: { findMany: vi.fn(async () => [city]) },
worldState: {
findFirst: vi.fn(async () => ({ config: {}, meta: { turntime: '2026-01-01 10:02:00' } })),
},
generalTurn: {
findMany: vi.fn(async () => [
{
generalId: 1,
turnIdx: 0,
actionCode: 'che_징병',
arg: { crewType: 1, amount: 300 },
},
]),
},
};
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: authenticated ? token() : null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'secret',
};
return { caller: appRouter.createCaller(context), db };
};
describe('world current-city command projection', () => {
it('returns the first five own-user turns as canonical action and args while redacting NPC turns', async () => {
const { caller, db } = fixture();
const result = await caller.world.getCurrentCity();
expect(result.generals.find((entry) => entry.id === 1)?.turns).toEqual([
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
]);
expect(result.generals.find((entry) => entry.id === 2)?.turns).toEqual([]);
expect(db.generalTurn.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { generalId: { in: [1] }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
})
);
});
it('keeps authentication and input validation in front of the city read model', async () => {
await expect(fixture(false).caller.world.getCurrentCity()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(fixture().caller.world.getCurrentCity({ cityId: 0 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
});
});
+113 -9
View File
@@ -431,13 +431,20 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
} }
}); });
test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => { test('general directory sorts the loaded rows locally in a stable three-state cycle', async ({ page }) => {
await install(page); const requestedOperations: string[] = [];
await install(page, 'general', [], requestedOperations);
await page.goto('general-list'); await page.goto('general-list');
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
await page.selectOption('#viewType', '8'); await page.selectOption('#viewType', '8');
await page.getByRole('button', { name: '정렬하기' }).click(); await page.getByRole('button', { name: '정렬하기' }).click();
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
await page.getByRole('button', { name: '정렬하기' }).click();
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20');
await page.getByRole('button', { name: '정렬하기' }).click();
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1);
await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81'); await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81');
await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6'); await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6');
@@ -489,10 +496,10 @@ test('directory sort controls stay legible in dark mode and sortable headers app
expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px'); expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px');
await page.mouse.up(); await page.mouse.up();
await page.getByRole('button', { name: '삭턴 기준 정렬' }).click(); await page.getByRole('button', { name: /^ /u }).click();
await expect(select).toHaveValue('8'); await expect(select).toHaveValue('8');
await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴'); await expect(page.locator('th[aria-sort="descending"]')).toContainText('삭턴');
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 844 }); await page.setViewportSize({ width: 500, height: 844 });
@@ -501,6 +508,82 @@ test('directory sort controls stay legible in dark mode and sortable headers app
await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true });
}); });
test('name and mixed-direction stable sorting reuse one response until explicit refresh', async ({ page }) => {
const requestedOperations: string[] = [];
await install(page, 'general', [], requestedOperations);
await page.goto('general-list');
const rows = page.locator('tbody tr[data-general-id]');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '20');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
await page.getByRole('button', { name: /^ /u }).click();
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '20');
await expect(
page.getByRole('columnheader').filter({ hasText: '통솔' }).locator('.legacy-sort-indicator')
).toHaveText('▼2');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1);
await page.getByRole('button', { name: '갱 신' }).click();
await expect
.poll(() => requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory').length)
.toBe(2);
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
});
test('trait and injury explanations appear on pointer hover and keyboard focus', async ({ page }, testInfo) => {
await install(page);
await page.goto('general-list');
const personality = page.locator('[data-directory-tooltip="personality-10"]');
await personality.hover();
await expect(personality.getByRole('tooltip')).toContainText('성격 · 대담');
await expect(personality.getByRole('tooltip')).toContainText('대담한 성격');
const domestic = page.locator('[data-directory-tooltip="special-domestic-10"]');
await domestic.hover();
await expect(domestic.getByRole('tooltip')).toContainText('내정 특기 · 상재');
await expect(domestic.getByRole('tooltip')).toContainText('상업 특기');
const war = page.locator('[data-directory-tooltip="special-war-10"]');
await war.focus();
await expect(war.getByRole('tooltip')).toContainText('전투 특기 · 귀모');
await expect(war.getByRole('tooltip')).toContainText('전투 특기');
const injury = page.locator('[data-directory-tooltip="injury-leadership-10"]');
await injury.hover();
await expect(injury.getByRole('tooltip')).toContainText('부상 10%');
await expect(injury.getByRole('tooltip')).toContainText('원래 통솔 90 → 적용 81');
const tooltipGeometry = await injury.getByRole('tooltip').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
left: rect.left,
right: window.innerWidth - rect.right,
display: style.display,
background: style.backgroundColor,
color: style.color,
fontSize: style.fontSize,
};
});
expect(tooltipGeometry.left).toBeGreaterThanOrEqual(8);
expect(tooltipGeometry.right).toBeGreaterThanOrEqual(8);
expect(tooltipGeometry).toMatchObject({
display: 'block',
background: 'rgb(16, 16, 16)',
color: 'rgb(245, 245, 245)',
fontSize: '12.5px',
});
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
});
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => { test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
await install(page); await install(page);
await page.goto('npc-list'); await page.goto('npc-list');
@@ -544,7 +627,8 @@ test('nation directory reuses only the public general-directory row on hover and
await expect(preview.locator('[data-general-card-id]')).toHaveCount(1); await expect(preview.locator('[data-general-card-id]')).toHaveCount(1);
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조'); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조');
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담'); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담');
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
await expect(preview).not.toContainText('user-'); await expect(preview).not.toContainText('user-');
await expect(preview).not.toContainText('secret'); await expect(preview).not.toContainText('secret');
@@ -687,8 +771,27 @@ test('nation and general directories rearrange for mobile and keep the tapped pr
await expect(page.locator('.general-table')).toBeHidden(); await expect(page.locator('.general-table')).toBeHidden();
await expect(page.locator('.general-card-list')).toBeVisible(); await expect(page.locator('.general-card-list')).toBeVisible();
await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2); await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2);
await expect(page.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(page.locator('[data-general-card-id="10"]')).toContainText('통솔81+6'); await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText(
'81'
);
await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6');
const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]');
await mobileInjury.focus();
await expect(mobileInjury.getByRole('tooltip')).toBeVisible();
const mobileTooltipGeometry = await mobileInjury.getByRole('tooltip').evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
left: rect.left,
right: window.innerWidth - rect.right,
width: rect.width,
innerWidth: window.innerWidth,
};
});
expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8);
expect(mobileTooltipGeometry.right).toBeGreaterThanOrEqual(8);
expect(mobileTooltipGeometry.width).toBeLessThanOrEqual(mobileTooltipGeometry.innerWidth - 16);
const generalMetrics = await page.locator('.directory-page').evaluate((element) => { const generalMetrics = await page.locator('.directory-page').evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -760,11 +863,12 @@ test('a reused image element falls back for each newly broken account icon', asy
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
}); });
test('a failed resort retains the selected value and existing rows', async ({ page }) => { test('a failed explicit refresh retains the local sort selection and existing rows', async ({ page }) => {
await install(page, 'error-after-load'); await install(page, 'error-after-load');
await page.goto('general-list'); await page.goto('general-list');
await page.selectOption('#viewType', '8'); await page.selectOption('#viewType', '8');
await page.getByRole('button', { name: '정렬하기' }).click(); await page.getByRole('button', { name: '정렬하기' }).click();
await page.getByRole('button', { name: '갱 신' }).click();
await expect(page.getByRole('alert')).toContainText('권한 확인 실패'); await expect(page.getByRole('alert')).toContainText('권한 확인 실패');
await expect(page.locator('#viewType')).toHaveValue('8'); await expect(page.locator('#viewType')).toHaveValue('8');
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
+123 -16
View File
@@ -210,7 +210,44 @@ const install = async (
} }
if (operation === 'general.me') return response(generalContext); if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMap') return response(mapFixture); if (operation === 'world.getMap') return response(mapFixture);
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.getCommandTable')
return response({
general: [
{
category: '군사',
values: [
{
key: 'che_징병',
name: '징병',
reqArg: true,
status: 'needsInput',
possible: true,
inputFields: [],
},
{
key: 'che_화계',
name: '화계',
reqArg: true,
status: 'needsInput',
possible: true,
inputFields: [],
},
],
},
],
nation: [],
inputOptions: {
cities: [{ value: 1, label: '업 (아국)' }],
nations: [],
generals: [],
crewTypes: [{ value: 1, label: '보병' }],
armTypes: [],
nationTypes: [],
colors: [],
items: {},
recruitment: null,
},
});
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response({ turns: [], revision: 0 }); return response({ turns: [], revision: 0 });
} }
@@ -373,7 +410,12 @@ const install = async (
crew: 500, crew: 500,
train: 90, train: 90,
atmos: 90, atmos: 90,
turns: denseCurrentCity ? ['징병', '훈련'] : ['징병'], turns: denseCurrentCity
? [
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
{ action: 'che_화계', args: { destCityId: 1 } },
]
: [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }],
}, },
...(denseCurrentCity ...(denseCurrentCity
? Array.from({ length: 12 }, (_, index) => ({ ? Array.from({ length: 12 }, (_, index) => ({
@@ -475,6 +517,27 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'global-info'); await go(page, 'global-info');
const desktopOptionsTrigger = page.getByRole('button', { name: '지도 옵션' });
await expect(desktopOptionsTrigger).toBeVisible();
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(52, 92, 133)');
await desktopOptionsTrigger.hover();
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(40, 73, 105)');
await expect(page.getByRole('button', { name: '도시명 표기 끄기' })).not.toBeVisible();
await desktopOptionsTrigger.click();
const cityNameToggle = page.getByRole('button', { name: '도시명 표기 끄기' });
await expect(cityNameToggle).toBeVisible();
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('.map-options-menu .map-toggle')).toHaveCount(1);
await page.keyboard.press('Tab');
await desktopOptionsTrigger.focus();
await expect(desktopOptionsTrigger).toHaveCSS('outline-style', 'solid');
await page.screenshot({ path: testInfo.outputPath('desktop-map-options-open.png'), fullPage: true });
await cityNameToggle.click();
await expect(page.locator('.map-area .city-name')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
const desktopCity = page.locator('.city-base').first(); const desktopCity = page.locator('.city-base').first();
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0); await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover(); await desktopCity.hover();
@@ -508,20 +571,48 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await install(mobilePage); await install(mobilePage);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
const mobileOptionsTrigger = mobilePage.getByRole('button', { name: '지도 옵션' });
await expect(mobileOptionsTrigger).toBeVisible();
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
await mobileOptionsTrigger.tap();
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(mobilePage.locator('.map-options-menu .map-toggle')).toHaveCount(2);
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }); const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
await expect(twoTapButton).toBeVisible(); await expect(twoTapButton).toBeVisible();
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false'); await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
const controlGeometry = await twoTapButton.evaluate((element) => { const controlGeometry = await mobilePage.locator('.map-controls').evaluate((element) => {
const rect = element.getBoundingClientRect(); const triggerRect = element.querySelector('.map-options-trigger')!.getBoundingClientRect();
const menuRect = element.querySelector('.map-options-menu')!.getBoundingClientRect();
const optionRects = Array.from(element.querySelectorAll('.map-options-menu .map-toggle')).map((option) => {
const rect = option.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom };
});
const mapRect = element.closest('.map-area')?.getBoundingClientRect(); const mapRect = element.closest('.map-area')?.getBoundingClientRect();
const style = getComputedStyle(element); const optionStyle = getComputedStyle(element.querySelector('.map-toggle')!);
return { return {
right: rect.right, trigger: {
bottom: rect.bottom, right: triggerRect.right,
mapRight: mapRect?.right, bottom: triggerRect.bottom,
mapBottom: mapRect?.bottom, top: triggerRect.top,
fontSize: style.fontSize, },
lineHeight: style.lineHeight, menu: {
left: menuRect.left,
right: menuRect.right,
top: menuRect.top,
bottom: menuRect.bottom,
},
optionRects,
map: mapRect
? {
left: mapRect.left,
right: mapRect.right,
top: mapRect.top,
bottom: mapRect.bottom,
}
: null,
fontSize: optionStyle.fontSize,
lineHeight: optionStyle.lineHeight,
documentWidth: document.documentElement.scrollWidth, documentWidth: document.documentElement.scrollWidth,
viewportWidth: document.documentElement.clientWidth, viewportWidth: document.documentElement.clientWidth,
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *')) overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
@@ -543,8 +634,20 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
overflowing: [], overflowing: [],
}); });
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1); expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1); expect(controlGeometry.map).not.toBeNull();
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1); expect(controlGeometry.map!.right - controlGeometry.trigger.right).toBeCloseTo(4, 1);
expect(controlGeometry.map!.bottom - controlGeometry.trigger.bottom).toBeCloseTo(4, 1);
expect(controlGeometry.menu.left).toBeGreaterThanOrEqual(controlGeometry.map!.left);
expect(controlGeometry.menu.right).toBeLessThanOrEqual(controlGeometry.map!.right);
expect(controlGeometry.menu.top).toBeGreaterThanOrEqual(controlGeometry.map!.top);
expect(controlGeometry.menu.bottom).toBeLessThan(controlGeometry.trigger.top);
expect(controlGeometry.optionRects).toHaveLength(2);
expect(controlGeometry.optionRects[0]!.bottom).toBeLessThanOrEqual(controlGeometry.optionRects[1]!.top);
expect(controlGeometry.optionRects[1]!.bottom).toBeLessThanOrEqual(controlGeometry.menu.bottom);
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-options-open.png'), fullPage: true });
await mobilePage.locator('.map-area').tap({ position: { x: 10, y: 10 } });
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
const mobileCities = mobilePage.locator('.city-base'); const mobileCities = mobilePage.locator('.city-base');
await mobileCities.nth(0).tap(); await mobileCities.nth(0).tap();
@@ -560,17 +663,19 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }); const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true'); await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
await mobilePage.reload(); await mobilePage.reload();
await expect(singleTapButton).toBeVisible(); await expect(singleTapButton).not.toBeVisible();
await mobilePage.locator('.city-base').nth(2).tap(); await mobilePage.locator('.city-base').nth(2).tap();
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
await mobilePage.locator('.city-base').first().tap(); await mobilePage.locator('.city-base').first().tap();
@@ -1019,8 +1124,10 @@ test('current-city wraps dense general names and only shrinks reserved turns', a
const rows = page.locator('.generals tbody tr'); const rows = page.locator('.generals tbody tr');
const reservedTurns = rows.nth(0).locator('.turns'); const reservedTurns = rows.nth(0).locator('.turns');
const npcTurns = rows.nth(1).locator('.turns'); const npcTurns = rows.nth(1).locator('.turns');
await expect(reservedTurns).toContainText('1 : 징병'); await expect(reservedTurns).toContainText('1 : 【보병】 300명 징병');
await expect(reservedTurns).toContainText('2 : 훈련'); await expect(reservedTurns).toContainText('2 : 【업】에 화계실행');
await expect(reservedTurns.locator('.turn-line').nth(0)).toHaveAttribute('title', '【보병】 300명 징병');
await expect(reservedTurns.locator('.turn-line').nth(1)).toHaveAttribute('title', '【업】에 화계실행');
await expect(reservedTurns).toHaveClass(/turns--reserved/); await expect(reservedTurns).toHaveClass(/turns--reserved/);
await expect(npcTurns).toHaveText('NPC 장수'); await expect(npcTurns).toHaveText('NPC 장수');
await expect(npcTurns).not.toHaveClass(/turns--reserved/); await expect(npcTurns).not.toHaveClass(/turns--reserved/);
+37 -1
View File
@@ -2186,7 +2186,43 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false)); await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(selectedMenu).not.toHaveAttribute('open', ''); await expect(selectedMenu).not.toHaveAttribute('open', '');
await page.locator('[data-main-target="commands"] .select-command').click(); const selectCommand = page.locator('[data-main-target="commands"] .select-command');
await expect(selectCommand).toHaveClass(/legacy-button--info/u);
await page.mouse.move(1, 1);
const measureSelectCommand = () =>
selectCommand.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
borderRadius: style.borderRadius,
backgroundColor: style.backgroundColor,
};
});
const selectDefault = await measureSelectCommand();
expect(selectDefault).toMatchObject({
height: 34,
marginTop: '0px',
borderBottomWidth: '4px',
borderRadius: '5.25px',
backgroundColor: 'rgb(52, 152, 219)',
});
await selectCommand.hover();
const selectHover = await measureSelectCommand();
expect(selectHover).toMatchObject({ height: 33, marginTop: '1px', borderBottomWidth: '3px' });
expect(selectHover.bottom).toBeCloseTo(selectDefault.bottom, 2);
const selectBox = await selectCommand.boundingBox();
if (!selectBox) throw new Error('select command control is not measurable');
await page.mouse.move(selectBox.x + selectBox.width / 2, selectBox.y + selectBox.height / 2);
await page.mouse.down();
const selectActive = await measureSelectCommand();
expect(selectActive).toMatchObject({ height: 32, marginTop: '2px', borderBottomWidth: '2px' });
expect(selectActive.bottom).toBeCloseTo(selectDefault.bottom, 2);
await page.mouse.up();
const picker = page.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
// The trigger can end up directly above a newly opened category button. // The trigger can end up directly above a newly opened category button.
@@ -205,6 +205,86 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
await expect(page.locator('#nation-general-list')).toContainText('?'); await expect(page.locator('#nation-general-list')).toContainText('?');
}); });
test('nation generals top controls share fixed Lumen state geometry on desktop and mobile', async ({
page,
}, testInfo) => {
await install(page);
const evidence: Record<string, unknown> = {};
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toBeVisible();
const controls = [
page.getByRole('button', { name: '돌아가기' }),
page.getByRole('button', { name: '갱신' }),
page.getByRole('button', { name: '보기 모드⌄' }),
page.getByRole('button', { name: '열 선택⌄' }),
];
const viewportEvidence: Record<string, unknown> = {};
for (const control of controls) {
const label = (await control.textContent())?.trim() ?? 'unknown';
await expect(control).toHaveClass(/legacy-button--fixed-height/u);
const measure = () =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
borderRadius: style.borderRadius,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
};
});
await page.mouse.move(viewport.width - 1, viewport.height - 1);
const base = await measure();
expect(base).toMatchObject({
height: 32,
marginTop: '0px',
borderBottomWidth: '4px',
borderRadius: '5.25px',
fontSize: '14px',
});
expect(base.fontFamily).toContain('Pretendard');
await control.hover();
const hover = await measure();
expect(hover).toMatchObject({ height: 31, marginTop: '1px', borderBottomWidth: '3px' });
expect(hover.bottom).toBeCloseTo(base.bottom, 2);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} control is not measurable`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await measure();
expect(active).toMatchObject({ height: 30, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(base.bottom, 2);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await page.mouse.up();
viewportEvidence[label] = { default: base, hover, active };
}
evidence[`${viewport.width}x${viewport.height}`] = viewportEvidence;
await page.screenshot({
path: testInfo.outputPath(`nation-general-buttons-${viewport.width}.png`),
fullPage: true,
});
}
await testInfo.attach('nation-general-button-geometry', {
body: JSON.stringify(evidence, null, 2),
contentType: 'application/json',
});
});
test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => { test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => {
await install(page); await install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
@@ -405,17 +485,20 @@ test('secret office renders five Ref-style command briefs and the forbidden erro
'5 : 휴식', '5 : 휴식',
]); ]);
await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여'); await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여');
const geometry = await page.locator('#secret-general-list .turns').first().evaluate((element) => { const geometry = await page
const rect = element.getBoundingClientRect(); .locator('#secret-general-list .turns')
const style = getComputedStyle(element); .first()
return { .evaluate((element) => {
width: rect.width, const rect = element.getBoundingClientRect();
height: rect.height, const style = getComputedStyle(element);
fontSize: style.fontSize, return {
textAlign: style.textAlign, width: rect.width,
horizontalOverflow: element.scrollWidth - element.clientWidth, height: rect.height,
}; fontSize: style.fontSize,
}); textAlign: style.textAlign,
horizontalOverflow: element.scrollWidth - element.clientWidth,
};
});
expect(geometry.width).toBeGreaterThanOrEqual(190); expect(geometry.width).toBeGreaterThanOrEqual(190);
expect(geometry.width).toBeLessThanOrEqual(230); expect(geometry.width).toBeLessThanOrEqual(230);
expect(geometry.height).toBeGreaterThanOrEqual(60); expect(geometry.height).toBeGreaterThanOrEqual(60);
@@ -395,7 +395,10 @@ test('join refresh shows the assigned preliminary group immediately with accessi
await refresh.focus(); await refresh.focus();
await expect(refresh).toBeFocused(); await expect(refresh).toBeFocused();
await refresh.hover(); await refresh.hover();
await expect(refresh).toHaveCSS('filter', 'brightness(1.25)'); await expect(refresh).toHaveCSS('filter', 'none');
await expect(refresh).toHaveCSS('height', '43px');
await expect(refresh).toHaveCSS('margin-top', '1px');
await expect(refresh).toHaveCSS('border-bottom-width', '3px');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
}); });
@@ -602,7 +605,11 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
await expect(dialog.getByText('예상 환수금 280')).toBeVisible(); await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
await dialog.getByLabel('베팅 금액').selectOption('50'); await dialog.getByLabel('베팅 금액').selectOption('50');
await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible(); await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible();
await persistScreenshot(page, 'tournament-betting-dialog-mobile', testInfo.outputPath('betting-dialog-mobile.webp')); await persistScreenshot(
page,
'tournament-betting-dialog-mobile',
testInfo.outputPath('betting-dialog-mobile.webp')
);
await dialog.getByRole('button', { name: '베팅 등록' }).click(); await dialog.getByRole('button', { name: '베팅 등록' }).click();
await expect(dialog).not.toBeVisible(); await expect(dialog).not.toBeVisible();
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.'); await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
@@ -144,7 +144,7 @@
/* /*
* Ref Bootstrap 5.2 + Lumen button family. This class owns the common raised * Ref Bootstrap 5.2 + Lumen button family. This class owns the common raised
* edge and pressed movement. Semantic modifiers below only select face, edge, * edge and pressed movement. Semantic modifiers below only select face, edge,
* and text colors; width and fixed-height compensation stay with the owner. * and text colors; width and the optional fixed-height value stay with the owner.
* Existing semantic modifiers also opt in for backward compatibility. * Existing semantic modifiers also opt in for backward compatibility.
*/ */
.legacy-button:is( .legacy-button:is(
@@ -173,6 +173,15 @@
vertical-align: middle; vertical-align: middle;
} }
/*
* Top bars and other fixed rows keep their owner-provided height while using
* the same Lumen edge movement. Shrinking the box with the edge keeps its
* bottom coordinate fixed instead of moving the whole control down.
*/
.legacy-button.legacy-button--fixed-height {
height: var(--legacy-button-height);
}
.legacy-button.legacy-button--secondary { .legacy-button.legacy-button--secondary {
--legacy-button-bg: var(--sammo-button-secondary-bg); --legacy-button-bg: var(--sammo-button-secondary-bg);
--legacy-button-border: var(--sammo-button-secondary-border); --legacy-button-border: var(--sammo-button-secondary-border);
@@ -213,6 +222,10 @@
background: var(--legacy-button-bg); background: var(--legacy-button-bg);
} }
.legacy-button.legacy-button--fixed-height:not(:disabled, [aria-disabled='true']):is(:hover, [aria-expanded='true']) {
height: calc(var(--legacy-button-height) - 1px);
}
.legacy-button:is( .legacy-button:is(
.legacy-button--lumen, .legacy-button--lumen,
.legacy-button--primary, .legacy-button--primary,
@@ -244,6 +257,10 @@
box-shadow: none; box-shadow: none;
} }
.legacy-button.legacy-button--fixed-height:not(:disabled, [aria-disabled='true']):active {
height: calc(var(--legacy-button-height) - 2px);
}
.legacy-button:is( .legacy-button:is(
.legacy-button--lumen, .legacy-button--lumen,
.legacy-button--primary, .legacy-button--primary,
@@ -588,7 +588,13 @@ const clickOutsideMenu = (event: Event) => {
</button> </button>
</div> </div>
</details> </details>
<button type="button" class="select-command" @click="togglePicker()">명령 선택 </button> <button
type="button"
class="legacy-button legacy-button--info legacy-button--fixed-height select-command"
@click="togglePicker()"
>
명령 선택
</button>
</div> </div>
<div class="queue-area"> <div class="queue-area">
@@ -806,8 +812,7 @@ const clickOutsideMenu = (event: Event) => {
} }
.control-pad > button, .control-pad > button,
.clock, .clock,
.legacy-menu > summary, .legacy-menu > summary {
.select-command {
box-sizing: border-box; box-sizing: border-box;
min-height: 34px; min-height: 34px;
border: 0; border: 0;
@@ -822,6 +827,12 @@ const clickOutsideMenu = (event: Event) => {
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
} }
.select-command {
--legacy-button-height: 34px;
display: grid;
place-items: center;
padding: 4px;
}
.clock { .clock {
background: #345c85; background: #345c85;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -1024,9 +1035,6 @@ const clickOutsideMenu = (event: Event) => {
grid-template-columns: 5fr 7fr; grid-template-columns: 5fr 7fr;
order: 1; order: 1;
} }
.advanced-actions > * {
border-radius: 0 !important;
}
.bottom-actions { .bottom-actions {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -0,0 +1,89 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string | null;
testId?: string;
}>();
</script>
<template>
<span
class="directory-tooltip"
:class="{ 'directory-tooltip--enabled': description }"
:tabindex="description ? 0 : undefined"
:data-directory-tooltip="testId"
>
<slot />
<span v-if="description" class="directory-tooltip__content" role="tooltip">
<strong>{{ title }}</strong>
<span>{{ description }}</span>
</span>
</span>
</template>
<style scoped>
.directory-tooltip {
position: relative;
display: inline;
min-width: 0;
}
.directory-tooltip--enabled {
cursor: help;
text-decoration: underline dotted rgb(150 210 255 / 85%);
text-underline-offset: 2px;
}
.directory-tooltip--enabled:focus-visible {
border-radius: 2px;
outline: 1px solid #6fc7ff;
outline-offset: 1px;
}
.directory-tooltip__content {
display: none;
position: absolute;
z-index: 30;
left: 50%;
bottom: calc(100% + 5px);
box-sizing: border-box;
width: max-content;
max-width: min(280px, calc(100vw - 16px));
transform: translateX(-50%);
border: 1px solid #8c8c8c;
border-radius: 3px;
padding: 7px 9px;
background: #101010;
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
color: #f5f5f5;
font-family: var(--sammo-font-sans);
font-size: 12.5px;
font-weight: 400;
line-height: 1.45;
text-align: left;
white-space: normal;
word-break: keep-all;
}
.directory-tooltip__content strong,
.directory-tooltip__content span {
display: block;
}
.directory-tooltip__content strong {
margin-bottom: 4px;
color: #7fd4ff;
font-size: 13px;
}
.directory-tooltip--enabled:hover > .directory-tooltip__content,
.directory-tooltip--enabled:focus > .directory-tooltip__content {
display: block;
}
@media (max-width: 600px) {
.directory-tooltip__content {
position: fixed;
right: 8px;
bottom: 8px;
left: 8px;
width: auto;
max-width: none;
transform: none;
}
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
import DirectoryTooltip from './DirectoryTooltip.vue';
const props = withDefaults(
defineProps<{
label: string;
value: number;
injury: number;
bonus?: number;
testId?: string;
}>(),
{ bonus: 0, testId: undefined }
);
const displayedValue = computed(() =>
props.injury > 0 ? Math.trunc((props.value * (100 - props.injury)) / 100) : props.value
);
const injuryDescription = computed(() =>
props.injury > 0
? `부상 ${props.injury}% · 원래 ${props.label} ${props.value} → 적용 ${displayedValue.value}`
: null
);
</script>
<template>
<DirectoryTooltip :title="`${label} 부상`" :description="injuryDescription" :test-id="testId">
<span :class="{ wounded: injury > 0 }">{{ displayedValue }}</span>
</DirectoryTooltip>
<span v-if="bonus > 0" class="leadership-bonus">+{{ bonus }}</span>
</template>
<style scoped>
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
</style>
@@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera
import { formatOfficerLevelText } from '../../utils/nationFormat'; import { formatOfficerLevelText } from '../../utils/nationFormat';
import { getNpcColor } from '../../utils/npcColor'; import { getNpcColor } from '../../utils/npcColor';
import type { GeneralDirectoryGeneral } from '../../types/directory'; import type { GeneralDirectoryGeneral } from '../../types/directory';
import type { GeneralDirectorySortCriterion, GeneralDirectorySortKey } from '../../utils/generalDirectorySort';
import DirectoryTooltip from './DirectoryTooltip.vue';
import GeneralDirectoryStat from './GeneralDirectoryStat.vue';
type SortDirection = 'ascending' | 'descending'; type SortDirection = 'ascending' | 'descending';
type Header = { type Header = {
label: string; label: string;
sort?: number; sort?: GeneralDirectorySortKey;
direction?: SortDirection;
title?: string;
}; };
const props = withDefaults( const props = withDefaults(
@@ -17,12 +18,12 @@ const props = withDefaults(
generals: GeneralDirectoryGeneral[]; generals: GeneralDirectoryGeneral[];
loading?: boolean; loading?: boolean;
layout?: 'responsive' | 'card'; layout?: 'responsive' | 'card';
activeSort?: number; sortCriteria?: readonly GeneralDirectorySortCriterion[];
}>(), }>(),
{ {
loading: false, loading: false,
layout: 'responsive', layout: 'responsive',
activeSort: undefined, sortCriteria: () => [],
} }
); );
@@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>();
const headers: ReadonlyArray<Header> = [ const headers: ReadonlyArray<Header> = [
{ label: '얼 굴' }, { label: '얼 굴' },
{ label: '이 름' }, { label: '이 름', sort: 0 },
{ label: '연령', sort: 14, direction: 'descending' }, { label: '연령', sort: 14 },
{ label: '성격', sort: 11, direction: 'descending' }, { label: '성격', sort: 11 },
{ label: '특기' }, { label: '특기' },
{ label: '레 벨', sort: 10, direction: 'descending' }, { label: '레 벨', sort: 10 },
{ label: '국 가', sort: 1, direction: 'ascending' }, { label: '국 가', sort: 1 },
{ label: '명 성', sort: 5, direction: 'descending' }, { label: '명 성', sort: 5 },
{ label: '계 급', sort: 6, direction: 'descending' }, { label: '계 급', sort: 6 },
{ label: '관 직', sort: 7, direction: 'descending' }, { label: '관 직', sort: 7 },
{ label: '통솔', sort: 2, direction: 'descending' }, { label: '통솔', sort: 2 },
{ label: '무력', sort: 3, direction: 'descending' }, { label: '무력', sort: 3 },
{ label: '지력', sort: 4, direction: 'descending' }, { label: '지력', sort: 4 },
{ label: '삭턴', sort: 8, direction: 'ascending' }, { label: '삭턴', sort: 8 },
{ label: '벌점', sort: 9, direction: 'descending' }, { label: '벌점', sort: 9 },
]; ];
const criterionIndex = (header: Header): number =>
header.sort === undefined ? -1 : props.sortCriteria.findIndex(({ key }) => key === header.sort);
const criterionFor = (header: Header): GeneralDirectorySortCriterion | undefined => {
const index = criterionIndex(header);
return index < 0 ? undefined : props.sortCriteria[index];
};
const ariaSort = (header: Header): SortDirection | undefined => const ariaSort = (header: Header): SortDirection | undefined =>
header.sort === props.activeSort ? header.direction : undefined; criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined;
const sortIndicator = (header: Header): string => {
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); const index = criterionIndex(header);
if (index < 0) return '↕';
const arrow = criterionFor(header)?.direction === 'ascending' ? '▲' : '▼';
return props.sortCriteria.length > 1 ? `${arrow}${index + 1}` : arrow;
};
const nextSortAction = (header: Header): string => {
const direction = criterionFor(header)?.direction;
if (!direction) return '내림차순';
return direction === 'descending' ? '오름차순' : '정렬 해제';
};
const sortHelp = (header: Header): string =>
`${header.label.replaceAll(' ', '')} ${nextSortAction(header)}. 같은 값은 이전 정렬 순서를 유지합니다.`;
</script> </script>
<template> <template>
@@ -81,17 +99,14 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
:aria-sort="ariaSort(header)" :aria-sort="ariaSort(header)"
> >
<button <button
v-if="header.sort !== undefined && activeSort !== undefined" v-if="header.sort !== undefined"
class="legacy-sort-header" class="legacy-sort-header"
type="button" type="button"
:aria-label="`${header.label.replaceAll(' ', '')} 기준 정렬`" :aria-label="sortHelp(header)"
:title="header.title ?? `${header.label.replaceAll(' ', '')} 기준 정렬`" :title="sortHelp(header)"
@click="emit('sort', header.sort)" @click="emit('sort', header.sort)"
> >
{{ header.label {{ header.label }}<span class="legacy-sort-indicator">{{ sortIndicator(header) }}</span>
}}<span class="legacy-sort-indicator">{{
header.sort === activeSort ? (header.direction === 'ascending' ? '▲' : '▼') : '↕'
}}</span>
</button> </button>
<template v-else>{{ header.label }}</template> <template v-else>{{ header.label }}</template>
</th> </th>
@@ -132,11 +147,30 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</td> </td>
<td class="center">{{ general.age }}세</td> <td class="center">{{ general.age }}세</td>
<td class="center"> <td class="center">
<span :title="general.personality.info">{{ general.personality.name }}</span> <DirectoryTooltip
:title="`성격 · ${general.personality.name}`"
:description="general.personality.info"
:test-id="`personality-${general.id}`"
>
{{ general.personality.name }}
</DirectoryTooltip>
</td> </td>
<td class="center"> <td class="center">
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> / <DirectoryTooltip
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span> :title="`내정 특기 · ${general.specialDomestic.name}`"
:description="general.specialDomestic.info"
:test-id="`special-domestic-${general.id}`"
>
{{ general.specialDomestic.name }}
</DirectoryTooltip>
/
<DirectoryTooltip
:title="`전투 특기 · ${general.specialWar.name}`"
:description="general.specialWar.info"
:test-id="`special-war-${general.id}`"
>
{{ general.specialWar.name }}
</DirectoryTooltip>
</td> </td>
<td class="center">Lv {{ general.experienceLevel }}</td> <td class="center">Lv {{ general.experienceLevel }}</td>
<td class="center">{{ general.nationName }}</td> <td class="center">{{ general.nationName }}</td>
@@ -144,22 +178,29 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
<td class="center">{{ general.dedicationText }}</td> <td class="center">{{ general.dedicationText }}</td>
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td> <td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership label="통솔"
}}</span :value="general.leadership"
><span v-if="general.leadershipBonus > 0" class="leadership-bonus" :injury="general.injury"
>+{{ general.leadershipBonus }}</span :bonus="general.leadershipBonus"
> :test-id="`injury-leadership-${general.id}`"
/>
</td> </td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength label="무력"
}}</span> :value="general.strength"
:injury="general.injury"
:test-id="`injury-strength-${general.id}`"
/>
</td> </td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence label="지력"
}}</span> :value="general.intelligence"
:injury="general.injury"
:test-id="`injury-intelligence-${general.id}`"
/>
</td> </td>
<td class="center">{{ general.killturn }}</td> <td class="center">{{ general.killturn }}</td>
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td> <td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
@@ -207,13 +248,33 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">성격</span> <span class="field-label">성격</span>
<span :title="general.personality.info">{{ general.personality.name }}</span> <DirectoryTooltip
:title="`성격 · ${general.personality.name}`"
:description="general.personality.info"
:test-id="`card-personality-${general.id}`"
>
{{ general.personality.name }}
</DirectoryTooltip>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">특기</span> <span class="field-label">특기</span>
<span :title="`${general.specialDomestic.info} / ${general.specialWar.info}`" <span>
>{{ general.specialDomestic.name }} / {{ general.specialWar.name }}</span <DirectoryTooltip
> :title="`내정 특기 · ${general.specialDomestic.name}`"
:description="general.specialDomestic.info"
:test-id="`card-special-domestic-${general.id}`"
>
{{ general.specialDomestic.name }}
</DirectoryTooltip>
/
<DirectoryTooltip
:title="`전투 특기 · ${general.specialWar.name}`"
:description="general.specialWar.info"
:test-id="`card-special-war-${general.id}`"
>
{{ general.specialWar.name }}
</DirectoryTooltip>
</span>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span> <span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span>
@@ -233,26 +294,31 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">통솔</span> <span class="field-label">통솔</span>
<span> <GeneralDirectoryStat
<span :class="{ wounded: general.injury > 0 }">{{ label="통솔"
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership :value="general.leadership"
}}</span :injury="general.injury"
><span v-if="general.leadershipBonus > 0" class="leadership-bonus" :bonus="general.leadershipBonus"
>+{{ general.leadershipBonus }}</span :test-id="`card-injury-leadership-${general.id}`"
> />
</span>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">무력</span> <span class="field-label">무력</span>
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength label="무력"
}}</span> :value="general.strength"
:injury="general.injury"
:test-id="`card-injury-strength-${general.id}`"
/>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">지력</span> <span class="field-label">지력</span>
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence label="지력"
}}</span> :value="general.intelligence"
:injury="general.injury"
:test-id="`card-injury-intelligence-${general.id}`"
/>
</div> </div>
<div class="general-card-field penalty-field"> <div class="general-card-field penalty-field">
<span class="field-label">벌점</span> <span class="field-label">벌점</span>
@@ -298,12 +364,6 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
.center { .center {
text-align: center; text-align: center;
} }
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.loading-cell { .loading-cell {
height: 64px; height: 64px;
text-align: center; text-align: center;
@@ -61,7 +61,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
> >
<div class="bottom-item"> <div class="bottom-item">
<button <button
class="bottom-trigger legacy-button legacy-button--navigation" class="bottom-trigger legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button" type="button"
data-bottom-menu="global" data-bottom-menu="global"
:aria-expanded="openId === 'global'" :aria-expanded="openId === 'global'"
@@ -132,7 +132,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-item nation-bottom-item"> <div class="bottom-item nation-bottom-item">
<button <button
class="bottom-trigger nation-trigger legacy-button legacy-button--lumen" class="bottom-trigger nation-trigger legacy-button legacy-button--lumen legacy-button--fixed-height"
:style="{ :style="{
'--legacy-button-bg': nationMenuColor, '--legacy-button-bg': nationMenuColor,
'--legacy-button-border': 'color-mix(in srgb, var(--nation-menu-color) 90%, #000)', '--legacy-button-border': 'color-mix(in srgb, var(--nation-menu-color) 90%, #000)',
@@ -178,7 +178,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-item"> <div class="bottom-item">
<button <button
class="bottom-trigger quick-trigger legacy-button legacy-button--dark" class="bottom-trigger quick-trigger legacy-button legacy-button--dark legacy-button--fixed-height"
type="button" type="button"
data-bottom-menu="quick" data-bottom-menu="quick"
:aria-expanded="openId === 'quick'" :aria-expanded="openId === 'quick'"
@@ -221,7 +221,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-refresh-controls"> <div class="bottom-refresh-controls">
<button <button
class="bottom-trigger auto-refresh-trigger legacy-button legacy-button--navigation" class="bottom-trigger auto-refresh-trigger legacy-button legacy-button--navigation legacy-button--fixed-height"
:class="{ active: realtimeEnabled }" :class="{ active: realtimeEnabled }"
type="button" type="button"
data-bottom-menu="auto-refresh" data-bottom-menu="auto-refresh"
@@ -232,7 +232,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong> <strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong>
</button> </button>
<button <button
class="bottom-trigger manual-refresh-trigger legacy-button legacy-button--dark" class="bottom-trigger manual-refresh-trigger legacy-button legacy-button--dark legacy-button--fixed-height"
type="button" type="button"
data-bottom-menu="manual-refresh" data-bottom-menu="manual-refresh"
aria-label="직접 갱신" aria-label="직접 갱신"
@@ -279,9 +279,9 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
} }
.bottom-trigger { .bottom-trigger {
--legacy-button-height: 45px;
box-sizing: border-box; box-sizing: border-box;
width: 125px; width: 125px;
height: 45px;
padding: 6px 4px; padding: 6px 4px;
font-family: inherit; font-family: inherit;
font-size: 16px; font-size: 16px;
@@ -316,15 +316,6 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
line-height: 1; line-height: 1;
} }
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover,
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] {
height: 44px;
}
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):active {
height: 43px;
}
.dropup-caret { .dropup-caret {
display: inline-block; display: inline-block;
margin-left: 4px; margin-left: 4px;
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref, useId } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core'; import { onClickOutside, useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import MapCityBasic from './MapCityBasic.vue'; import MapCityBasic from './MapCityBasic.vue';
import MapCityDetail from './MapCityDetail.vue'; import MapCityDetail from './MapCityDetail.vue';
@@ -104,6 +104,9 @@ const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
const mapArea = ref<HTMLElement | null>(null); const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null); const mapBody = ref<HTMLElement | null>(null);
const mapControls = ref<HTMLElement | null>(null);
const mapOptionsOpen = ref(false);
const mapOptionsMenuId = `map-options-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody); const { width: mapBodyWidth } = useElementSize(mapBody);
const { elementX, elementY } = useMouseInElement(mapArea); const { elementX, elementY } = useMouseInElement(mapArea);
@@ -416,6 +419,16 @@ const clearTouchPreview = () => {
setHoveredCity(null); setHoveredCity(null);
}; };
const closeMapOptions = () => {
mapOptionsOpen.value = false;
};
const toggleMapOptions = () => {
mapOptionsOpen.value = !mapOptionsOpen.value;
};
onClickOutside(mapControls, closeMapOptions);
const touchCity = (cityId: number, event: TouchEvent) => { const touchCity = (cityId: number, event: TouchEvent) => {
if (touchPreviewCityId.value !== cityId) { if (touchPreviewCityId.value !== cityId) {
touchPreviewCityId.value = cityId; touchPreviewCityId.value = cityId;
@@ -460,7 +473,10 @@ const selectCity = (cityId: number) => {
class="map-area" class="map-area"
:class="[mapThemeClass, mapSeasonClass]" :class="[mapThemeClass, mapSeasonClass]"
:style="{ width: mapWidth, height: mapHeight }" :style="{ width: mapWidth, height: mapHeight }"
@click="clearTouchPreview" @click="
clearTouchPreview();
closeMapOptions();
"
> >
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" /> <div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
<div class="map-layer map-bglayer2" /> <div class="map-layer map-bglayer2" />
@@ -495,18 +511,43 @@ const selectCity = (cityId: number) => {
<div class="tooltip-title">{{ hoveredCityTitle }}</div> <div class="tooltip-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div> <div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div> </div>
<div class="map-controls"> <div ref="mapControls" class="map-controls" @keydown.esc.stop="closeMapOptions">
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName"> <div
도시명 표기 {{ showCityName ? '끄기' : '켜기' }} v-show="mapOptionsOpen"
</button> :id="mapOptionsMenuId"
<button class="map-options-menu"
v-if="hasTouchInput && !isSelectionMap && !props.readonly" role="group"
class="map-toggle map-toggle-single-tap" aria-label="지도 옵션 메뉴"
:class="{ active: singleTapNavigation }" @click.stop
:aria-pressed="singleTapNavigation"
@click.stop="toggleSingleTapNavigation"
> >
두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }} <button
class="map-toggle"
:class="{ active: showCityName }"
:aria-pressed="showCityName"
@click="mapStore.toggleCityName"
>
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button>
<button
v-if="hasTouchInput && !isSelectionMap && !props.readonly"
class="map-toggle map-toggle-single-tap"
:class="{ active: singleTapNavigation }"
:aria-pressed="singleTapNavigation"
@click="toggleSingleTapNavigation"
>
두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
</button>
</div>
<button
type="button"
class="map-options-trigger"
aria-label="지도 옵션"
title="지도 옵션"
:aria-controls="mapOptionsMenuId"
:aria-expanded="mapOptionsOpen"
@click.stop="toggleMapOptions"
>
<span aria-hidden="true"></span>
</button> </button>
</div> </div>
</div> </div>
@@ -591,14 +632,68 @@ const selectCity = (cityId: number) => {
.map-controls { .map-controls {
position: absolute; position: absolute;
z-index: 4; z-index: 4;
right: 4px; inset: 4px;
bottom: 4px; pointer-events: none;
}
.map-options-trigger {
position: absolute;
right: 0;
bottom: 0;
display: grid;
box-sizing: border-box;
width: 30px;
height: 28px;
place-items: center;
border: 1px solid #6c757d;
border-radius: 2px;
padding: 0;
background: #345c85;
color: #fff;
cursor: pointer;
font-size: 17px;
line-height: 1;
pointer-events: auto;
}
.map-options-trigger:hover,
.map-options-trigger:focus-visible,
.map-options-trigger[aria-expanded='true'] {
border-color: #b7cadc;
background: #284969;
}
.map-options-trigger:active {
background: #1f3a54;
}
.map-options-trigger:focus-visible {
outline: 2px solid #fff;
outline-offset: 1px;
}
.map-options-menu {
position: absolute;
right: 0;
bottom: 32px;
display: flex; display: flex;
max-width: 100%;
max-height: calc(100% - 32px);
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: stretch;
overflow-y: auto;
border: 1px solid #6c757d;
border-radius: 2px;
padding: 3px;
background: rgba(11, 11, 11, 0.94);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
pointer-events: auto;
} }
.map-toggle { .map-toggle {
box-sizing: border-box;
width: max-content;
max-width: 100%;
border: 1px solid #6c757d; border: 1px solid #6c757d;
border-radius: 2px; border-radius: 2px;
padding: 3px 7px; padding: 3px 7px;
@@ -607,6 +702,12 @@ const selectCity = (cityId: number) => {
font-size: 11px; font-size: 11px;
line-height: 18px; line-height: 18px;
cursor: pointer; cursor: pointer;
text-align: left;
white-space: nowrap;
}
.map-toggle + .map-toggle {
margin-top: 3px;
} }
.map-toggle.active { .map-toggle.active {
@@ -14,6 +14,7 @@ defineProps<{
<button <button
type="button" type="button"
role="tab" role="tab"
class="legacy-button legacy-button--dark legacy-button--fixed-height"
:aria-selected="activePage === 'tournament'" :aria-selected="activePage === 'tournament'"
:class="{ active: activePage === 'tournament' }" :class="{ active: activePage === 'tournament' }"
@click="navigate" @click="navigate"
@@ -25,6 +26,7 @@ defineProps<{
<button <button
type="button" type="button"
role="tab" role="tab"
class="legacy-button legacy-button--dark legacy-button--fixed-height"
:aria-selected="activePage === 'betting'" :aria-selected="activePage === 'betting'"
:class="{ active: activePage === 'betting' }" :class="{ active: activePage === 'betting' }"
@click="navigate" @click="navigate"
@@ -34,7 +36,13 @@ defineProps<{
</RouterLink> </RouterLink>
</nav> </nav>
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="navigate"
>
닫기
</button>
</RouterLink> </RouterLink>
</div> </div>
</header> </header>
@@ -64,33 +72,21 @@ defineProps<{
gap: 4px; gap: 4px;
} }
button { button {
height: 44px; --legacy-button-height: 44px;
margin: 0; margin: 0;
border: 1px solid #666;
border-radius: 5.25px;
background: #444;
color: #fff;
font-size: 14px; font-size: 14px;
line-height: 18px;
cursor: pointer;
} }
.tournament-page-tabs button { .tournament-page-tabs button {
min-width: 72px; min-width: 72px;
padding: 10px 12px; padding: 10px 12px;
} }
.tournament-page-tabs button.active { .tournament-page-tabs button.active {
border-color: #f39c12; --legacy-button-bg: #8a5b13;
background: #8a5b13; --legacy-button-border: #704a0f;
} }
.close-button { .close-button {
width: 88px; width: 88px;
padding: 10px 16px; padding: 10px 16px;
border-color: #375a7f;
background: #375a7f;
}
button:hover,
button:focus {
filter: brightness(1.25);
} }
button:focus-visible { button:focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
@@ -113,7 +113,7 @@ watch(
background: #101010; background: #101010;
box-shadow: 0 3px 12px rgb(0 0 0 / 65%); box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
color: #f5f5f5; color: #f5f5f5;
font-family: Pretendard, sans-serif; font-family: var(--sammo-font-sans);
font-size: 12.5px; font-size: 12.5px;
line-height: 1.45; line-height: 1.45;
text-align: left; text-align: left;
@@ -0,0 +1,100 @@
export type GeneralDirectorySortKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
export type GeneralDirectorySortDirection = 'ascending' | 'descending';
export type GeneralDirectorySortCriterion = {
key: GeneralDirectorySortKey;
direction: GeneralDirectorySortDirection;
};
type TraitValue = { key: string };
export type GeneralDirectorySortable = {
name: string;
nationId: number;
leadership: number;
strength: number;
intelligence: number;
experience: number;
dedication: number;
officerLevel: number;
killturn: number;
refreshScoreTotal: number;
personality: TraitValue;
specialDomestic: TraitValue;
specialWar: TraitValue;
age: number;
npcState: number;
};
const koreanNameCollator = new Intl.Collator('ko-KR', { numeric: true, sensitivity: 'base' });
const compareString = (left: string, right: string): number => {
if (left === right) return 0;
return left < right ? -1 : 1;
};
const compareByKey = <T extends GeneralDirectorySortable>(left: T, right: T, key: GeneralDirectorySortKey): number => {
switch (key) {
case 0:
return koreanNameCollator.compare(left.name, right.name);
case 1:
return left.nationId - right.nationId;
case 2:
return left.leadership - right.leadership;
case 3:
return left.strength - right.strength;
case 4:
return left.intelligence - right.intelligence;
case 5:
case 10:
return left.experience - right.experience;
case 6:
return left.dedication - right.dedication;
case 7:
return left.officerLevel - right.officerLevel;
case 8:
return left.killturn - right.killturn;
case 9:
return left.refreshScoreTotal - right.refreshScoreTotal;
case 11:
return compareString(left.personality.key, right.personality.key);
case 12:
return compareString(left.specialDomestic.key, right.specialDomestic.key);
case 13:
return compareString(left.specialWar.key, right.specialWar.key);
case 14:
return left.age - right.age;
case 15:
return left.npcState - right.npcState;
}
};
export const advanceGeneralDirectorySort = (
current: readonly GeneralDirectorySortCriterion[],
key: GeneralDirectorySortKey
): GeneralDirectorySortCriterion[] => {
const existing = current.find((criterion) => criterion.key === key);
const remaining = current.filter((criterion) => criterion.key !== key);
if (!existing) return [{ key, direction: 'descending' }, ...remaining];
if (existing.direction === 'descending') return [{ key, direction: 'ascending' }, ...remaining];
return remaining;
};
export const sortGeneralDirectory = <T extends GeneralDirectorySortable>(
source: readonly T[],
criteria: readonly GeneralDirectorySortCriterion[]
): T[] => {
if (criteria.length === 0) return [...source];
return source
.map((general, originalIndex) => ({ general, originalIndex }))
.sort((left, right) => {
for (const criterion of criteria) {
const compared = compareByKey(left.general, right.general, criterion.key);
if (compared !== 0) return criterion.direction === 'ascending' ? compared : -compared;
}
return left.originalIndex - right.originalIndex;
})
.map(({ general }) => general);
};
+31 -62
View File
@@ -197,20 +197,31 @@ onMounted(() => {
:class="activeTab === 'resource' ? 'resource-page' : 'unique-page'" :class="activeTab === 'resource' ? 'resource-page' : 'unique-page'"
> >
<header class="top-back-bar bg0"> <header class="top-back-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button> <button
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview"> class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closeWindow"
>
닫기
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height reload-button"
type="button"
:disabled="loading"
@click="loadOverview"
>
갱신 갱신
</button> </button>
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1> <h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
<button <button
class="legacy-button tab-button" class="legacy-button legacy-button--dark tab-button"
:aria-pressed="activeTab === 'resource'" :aria-pressed="activeTab === 'resource'"
@click="activeTab = 'resource'" @click="activeTab = 'resource'"
> >
/ /
</button> </button>
<button <button
class="legacy-button tab-button" class="legacy-button legacy-button--dark tab-button"
:aria-pressed="activeTab === 'unique'" :aria-pressed="activeTab === 'unique'"
@click="activeTab = 'unique'" @click="activeTab = 'unique'"
> >
@@ -318,7 +329,12 @@ onMounted(() => {
step="10" step="10"
required required
/> />
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button> <button
class="legacy-button legacy-button--dark"
:disabled="actionBusy || selectedResource.isCallerHost"
>
입찰
</button>
</form> </form>
<h3 class="subsection-title">경매 등록</h3> <h3 class="subsection-title">경매 등록</h3>
@@ -331,7 +347,7 @@ onMounted(() => {
<legend>매물</legend> <legend>매물</legend>
<div class="item-toggle"> <div class="item-toggle">
<button <button
class="legacy-button" class="legacy-button legacy-button--dark"
type="button" type="button"
:aria-pressed="openForm.type === 'BUY_RICE'" :aria-pressed="openForm.type === 'BUY_RICE'"
@click="openForm.type = 'BUY_RICE'" @click="openForm.type = 'BUY_RICE'"
@@ -339,7 +355,7 @@ onMounted(() => {
</button> </button>
<button <button
class="legacy-button" class="legacy-button legacy-button--dark"
type="button" type="button"
:aria-pressed="openForm.type === 'SELL_RICE'" :aria-pressed="openForm.type === 'SELL_RICE'"
@click="openForm.type = 'SELL_RICE'" @click="openForm.type = 'SELL_RICE'"
@@ -363,7 +379,7 @@ onMounted(() => {
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span> <span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" /> <input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
</label> </label>
<button class="legacy-button register-button" :disabled="actionBusy">등록</button> <button class="legacy-button legacy-button--dark register-button" :disabled="actionBusy">등록</button>
</form> </form>
<h3 class="subsection-title">이전 경매(최근 20)</h3> <h3 class="subsection-title">이전 경매(최근 20)</h3>
@@ -408,7 +424,7 @@ onMounted(() => {
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트) 유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
</label> </label>
<input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required /> <input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required />
<button class="legacy-button" :disabled="actionBusy">입찰</button> <button class="legacy-button legacy-button--dark" :disabled="actionBusy">입찰</button>
</form> </form>
</template> </template>
</section> </section>
@@ -480,7 +496,9 @@ onMounted(() => {
</section> </section>
<footer class="bottom-bar bg0"> <footer class="bottom-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button> <button class="legacy-button legacy-button--navigation close-button" type="button" @click="closeWindow">
닫기
</button>
</footer> </footer>
</main> </main>
</template> </template>
@@ -530,66 +548,17 @@ onMounted(() => {
line-height: 32px; line-height: 32px;
text-align: center; text-align: center;
} }
.legacy-button {
box-sizing: border-box;
border: solid #3d3d3d;
border-width: 0 1px 4px;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #fff;
background: #444;
font: inherit;
font-weight: 700;
line-height: 21px;
cursor: pointer;
}
.legacy-button:hover,
.legacy-button:focus {
border-color: #353535;
background: #393939;
}
.legacy-button:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.legacy-button:active,
.legacy-button[aria-pressed='true'] {
border-color: #303030;
background: #333;
}
.legacy-button:disabled {
cursor: default;
opacity: 0.65;
}
.close-button, .close-button,
.reload-button { .reload-button {
margin-right: 2px; margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.close-button:hover,
.close-button:focus,
.reload-button:hover,
.reload-button:focus {
border-color: #004523;
background: #004a25;
} }
.top-back-bar .close-button, .top-back-bar .close-button,
.top-back-bar .reload-button { .top-back-bar .reload-button {
height: 32px; --legacy-button-height: 32px;
}
.tab-button {
border-color: #3d3d3d;
background: #444;
} }
.tab-button[aria-pressed='true'] { .tab-button[aria-pressed='true'] {
border-color: #3d3d3d; --legacy-button-bg: #333;
background: #444; --legacy-button-border: #303030;
}
.tab-button:hover,
.tab-button:focus {
border-color: #3d3d3d;
background: #444;
} }
.section-title, .section-title,
.subsection-title, .subsection-title,
@@ -236,9 +236,20 @@ onMounted(() => {
<main class="ref-shell battle-page"> <main class="ref-shell battle-page">
<header class="battle-top legacy-bg0"> <header class="battle-top legacy-bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="battle-nav" type="button" @click="navigate"> 닫기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
type="button"
@click="navigate"
>
닫기
</button>
</RouterLink> </RouterLink>
<button class="battle-nav" @click="loadBattleCenter">갱신</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
@click="loadBattleCenter"
>
갱신
</button>
<h1>감찰부</h1> <h1>감찰부</h1>
<div></div> <div></div>
<div></div> <div></div>
@@ -305,7 +316,9 @@ onMounted(() => {
</section> </section>
<footer class="battle-footer legacy-bg0"> <footer class="battle-footer legacy-bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="battle-nav" type="button" @click="navigate"> 닫기</button> <button class="legacy-button legacy-button--navigation battle-nav" type="button" @click="navigate">
닫기
</button>
</RouterLink> </RouterLink>
</footer> </footer>
</main> </main>
@@ -418,17 +431,11 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.battle-nav { .battle-nav {
--legacy-button-height: 32px;
box-sizing: border-box; box-sizing: border-box;
height: 32px;
margin-right: 2px; margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid; display: grid;
place-items: center; place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none; text-decoration: none;
} }
.battle-footer { .battle-footer {
+23 -16
View File
@@ -130,7 +130,13 @@ const placeBet = async () => {
<main id="tournament-betting-container" class="betting-page"> <main id="tournament-betting-container" class="betting-page">
<TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" /> <TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" />
<section class="toolbar bg0"> <section class="toolbar bg0">
<button type="button" @click="load">갱신</button> <button
class="legacy-button legacy-button--secondary legacy-button--fixed-height"
type="button"
@click="load"
>
갱신
</button>
<span v-if="loading">불러오는 중...</span> <span v-if="loading">불러오는 중...</span>
<span v-if="message" role="status">{{ message }}</span> <span v-if="message" role="status">{{ message }}</span>
</section> </section>
@@ -159,12 +165,7 @@ const placeBet = async () => {
@request-bet="openBetDialog" @request-bet="openBetDialog"
/> />
<dialog <dialog ref="betDialog" class="bet-dialog" aria-labelledby="bet-dialog-title" @close="selectedTarget = null">
ref="betDialog"
class="bet-dialog"
aria-labelledby="bet-dialog-title"
@close="selectedTarget = null"
>
<form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet"> <form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet">
<header> <header>
<h2 id="bet-dialog-title">베팅하기</h2> <h2 id="bet-dialog-title">베팅하기</h2>
@@ -194,9 +195,13 @@ const placeBet = async () => {
<span aria-hidden="true">×</span> <span aria-hidden="true">×</span>
<span class="gold-color">{{ selectedAmount }}</span> <span class="gold-color">{{ selectedAmount }}</span>
<span aria-hidden="true">=</span> <span aria-hidden="true">=</span>
<strong class="return-color">예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong> <strong class="return-color"
>예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong
>
</output> </output>
<p class="bet-preview-note">현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.</p> <p class="bet-preview-note">
현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.
</p>
<p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p> <p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p>
<footer> <footer>
<button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button> <button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button>
@@ -289,7 +294,9 @@ const placeBet = async () => {
</section> </section>
<footer class="betting-footer bg0"> <footer class="betting-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="legacy-button legacy-button--navigation close-button" type="button" @click="navigate">
닫기
</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
@@ -334,8 +341,8 @@ const placeBet = async () => {
text-align: left; text-align: left;
} }
.toolbar button { .toolbar button {
--legacy-button-height: 44px;
min-width: 72px; min-width: 72px;
height: 44px;
padding: 10px 16px; padding: 10px 16px;
font-size: 14px; font-size: 14px;
} }
@@ -380,7 +387,7 @@ select {
background: #000; background: #000;
color: #fff; color: #fff;
} }
button { button:not(.legacy-button) {
height: 35.5px; height: 35.5px;
color: #fff; color: #fff;
background: #444; background: #444;
@@ -388,16 +395,16 @@ button {
border-radius: 5.25px; border-radius: 5.25px;
cursor: pointer; cursor: pointer;
} }
button:hover, button:not(.legacy-button):hover,
button:focus { button:not(.legacy-button):focus {
filter: brightness(1.25); filter: brightness(1.25);
} }
button:focus-visible, button:not(.legacy-button):focus-visible,
select:focus-visible { select:focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
outline-offset: 1px; outline-offset: 1px;
} }
button:disabled, button:not(.legacy-button):disabled,
select:disabled { select:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
+20 -53
View File
@@ -126,7 +126,13 @@ onMounted(() => {
<div v-if="accessChecked && !canAccess" class="legacy-raw-access-error" role="alert">{{ errorMessage }}</div> <div v-if="accessChecked && !canAccess" class="legacy-raw-access-error" role="alert">{{ errorMessage }}</div>
<main v-else id="container" class="legacy-board-page"> <main v-else id="container" class="legacy-board-page">
<header class="top-back-bar bg0"> <header class="top-back-bar bg0">
<button class="legacy-button back-button" type="button" @click="closeBoard">돌아가기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button"
type="button"
@click="closeBoard"
>
돌아가기
</button>
<div></div> <div></div>
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<div></div> <div></div>
@@ -163,7 +169,7 @@ onMounted(() => {
<div></div> <div></div>
<button <button
id="submitArticle" id="submitArticle"
class="legacy-button legacy-button--secondary" class="legacy-button legacy-button--secondary legacy-button--fixed-height"
type="button" type="button"
@click="submitArticle" @click="submitArticle"
> >
@@ -222,7 +228,7 @@ onMounted(() => {
@keyup.enter="submitComment(article.id)" @keyup.enter="submitComment(article.id)"
/> />
<button <button
class="legacy-button submit-comment" class="legacy-button legacy-button--dark legacy-button--fixed-height submit-comment"
type="button" type="button"
@click="submitComment(article.id)" @click="submitComment(article.id)"
> >
@@ -235,7 +241,13 @@ onMounted(() => {
</section> </section>
<footer class="bottom-bar bg0"> <footer class="bottom-bar bg0">
<button class="legacy-button back-button" type="button" @click="closeBoard">돌아가기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button"
type="button"
@click="closeBoard"
>
돌아가기
</button>
</footer> </footer>
</template> </template>
</main> </main>
@@ -296,47 +308,9 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.legacy-button {
min-height: 31px;
box-sizing: border-box;
border: 1px solid #3d3d3d;
border-radius: 4px;
padding: 4px 12px;
color: #fff;
background: #444;
font: inherit;
font-weight: 600;
line-height: 1.5;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.legacy-button:hover {
border-color: #3d3d3d;
background: #444;
}
.legacy-button:focus-visible {
outline: none;
}
.legacy-button:active {
border-color: #3d3d3d;
background: #444;
}
.back-button { .back-button {
height: 32px; --legacy-button-height: 32px;
margin-right: 2px; margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.back-button:hover,
.back-button:focus {
border-color: #004523;
background: #004a25;
} }
.board-state { .board-state {
@@ -414,17 +388,10 @@ onMounted(() => {
} }
.article-submit-row .legacy-button { .article-submit-row .legacy-button {
--legacy-button-height: 35.5px;
width: auto; width: auto;
min-height: 35.5px;
margin-right: 10.5px; margin-right: 10.5px;
margin-left: 10.5px; margin-left: 10.5px;
transition: none;
}
.article-submit-row .legacy-button:hover,
.article-submit-row .legacy-button:focus,
.article-submit-row .legacy-button:active {
border-color: transparent;
} }
.article-frame { .article-frame {
@@ -513,8 +480,8 @@ onMounted(() => {
} }
.submit-comment { .submit-comment {
--legacy-button-height: 29.375px;
width: 83.333px; width: 83.333px;
min-height: 29.375px;
padding-top: 2px; padding-top: 2px;
padding-bottom: 2px; padding-bottom: 2px;
flex: 0 0 auto; flex: 0 0 auto;
@@ -529,9 +496,9 @@ onMounted(() => {
} }
.bottom-bar .back-button { .bottom-bar .back-button {
--legacy-button-height: 35.5px;
display: inline-block; display: inline-block;
width: 71px; width: 71px;
height: 35.5px;
margin: 0; margin: 0;
padding-right: 6px; padding-right: 6px;
padding-left: 6px; padding-left: 6px;
+17 -11
View File
@@ -318,8 +318,19 @@ const repeatTurns = async (amount: number) => {
<template> <template>
<main class="chief-page"> <main class="chief-page">
<header class="chief-top legacy-bg0"> <header class="chief-top legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button> <button
<button class="chief-nav" @click="loadChiefCenter">갱신</button> class="legacy-button legacy-button--navigation legacy-button--fixed-height chief-nav"
type="button"
@click="router.push('/')"
>
돌아가기
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height chief-nav"
@click="loadChiefCenter"
>
갱신
</button>
<h1>사령부</h1> <h1>사령부</h1>
<div></div> <div></div>
<div></div> <div></div>
@@ -442,7 +453,9 @@ const repeatTurns = async (amount: number) => {
</template> </template>
</div> </div>
<footer class="chief-footer legacy-bg0"> <footer class="chief-footer legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button> <button class="legacy-button legacy-button--navigation chief-nav" type="button" @click="router.push('/')">
돌아가기
</button>
</footer> </footer>
</main> </main>
</template> </template>
@@ -643,19 +656,12 @@ const repeatTurns = async (amount: number) => {
text-align: center; text-align: center;
} }
.chief-nav { .chief-nav {
--legacy-button-height: 32px;
box-sizing: border-box; box-sizing: border-box;
height: 32px;
margin-right: 2px; margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid; display: grid;
place-items: center; place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none; text-decoration: none;
cursor: pointer;
} }
.layout-desktop { .layout-desktop {
display: block; display: block;
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat'; import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
@@ -9,10 +11,12 @@ import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>; type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
type General = Result['generals'][number]; type General = Result['generals'][number];
type ReservedCommand = General['turns'][number];
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const data = ref<Result | null>(null); const data = ref<Result | null>(null);
const commandTable = ref<CommandTable | null>(null);
const error = ref(''); const error = ref('');
const selected = ref<number>(); const selected = ref<number>();
let loadSequence = 0; let loadSequence = 0;
@@ -29,8 +33,10 @@ const load = async (cityId?: number) => {
const sequence = ++loadSequence; const sequence = ++loadSequence;
try { try {
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined); const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
const table = await trpc.turns.getCommandTable.query({ generalId: result.me.id });
if (sequence !== loadSequence) return; if (sequence !== loadSequence) return;
data.value = result; data.value = result;
commandTable.value = table;
selected.value = result.city.id; selected.value = result.city.id;
error.value = ''; error.value = '';
} catch (cause) { } catch (cause) {
@@ -76,6 +82,8 @@ const defenceTrainText = (value: number | null) => {
return '△'; return '△';
}; };
const generalImage = (general: General): string => resolveGeneralIconUrl(general); const generalImage = (general: General): string => resolveGeneralIconUrl(general);
const commandBrief = (command: ReservedCommand): string =>
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
</script> </script>
<template> <template>
@@ -282,8 +290,12 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
<td>{{ general.atmos ?? '?' }}</td> <td>{{ general.atmos ?? '?' }}</td>
<td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }"> <td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }">
<template v-if="general.turns.length"> <template v-if="general.turns.length">
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line" <span
>{{ index + 1 }} : {{ turn }}</span v-for="(turn, index) in general.turns"
:key="index"
class="turn-line"
:title="commandBrief(turn)"
>{{ index + 1 }} : {{ commandBrief(turn) }}</span
> >
</template> </template>
<template v-else-if="general.npcState > 1">NPC 장수</template> <template v-else-if="general.npcState > 1">NPC 장수</template>
+41 -15
View File
@@ -1,15 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue'; import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import LegacySortControls from '../components/ui/LegacySortControls.vue'; import LegacySortControls from '../components/ui/LegacySortControls.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import type { GeneralDirectoryGeneral } from '../types/directory'; import type { GeneralDirectoryGeneral } from '../types/directory';
import {
advanceGeneralDirectorySort,
sortGeneralDirectory,
type GeneralDirectorySortCriterion,
type GeneralDirectorySortKey,
} from '../utils/generalDirectorySort';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; const sortOptions: Array<{ value: GeneralDirectorySortKey; label: string }> = [
{ value: 0, label: '이름' },
const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 1, label: '국가' }, { value: 1, label: '국가' },
{ value: 2, label: '통솔' }, { value: 2, label: '통솔' },
{ value: 3, label: '무력' }, { value: 3, label: '무력' },
@@ -27,18 +33,26 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 15, label: 'NPC' }, { value: 15, label: 'NPC' },
]; ];
const sort = ref<SortKey>(9); const selectedSort = ref<GeneralDirectorySortKey>(9);
const generals = ref<GeneralDirectoryGeneral[]>([]); const sourceGenerals = ref<GeneralDirectoryGeneral[]>([]);
const sortCriteria = ref<GeneralDirectorySortCriterion[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const router = useRouter(); const router = useRouter();
const { info: showInfoToast } = useGameFeedback();
const generals = computed(() => sortGeneralDirectory(sourceGenerals.value, sortCriteria.value));
const loadDirectory = async () => { const loadDirectory = async () => {
if (loading.value) {
showInfoToast('이미 장수 일람을 갱신하고 있습니다.');
return;
}
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const result = await trpc.world.getGeneralDirectory.query({ sort: sort.value }); const result = await trpc.world.getGeneralDirectory.query({ sort: 9 });
generals.value = result.generals; sourceGenerals.value = Array.isArray(result.generals) ? result.generals : [];
} catch (cause) { } catch (cause) {
error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.'; error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.';
} finally { } finally {
@@ -47,12 +61,12 @@ const loadDirectory = async () => {
}; };
const updateSort = (value: number): void => { const updateSort = (value: number): void => {
sort.value = value as SortKey; selectedSort.value = value as GeneralDirectorySortKey;
}; };
const sortByHeader = (value: number): void => { const sortByHeader = (value: number): void => {
updateSort(value); updateSort(value);
void loadDirectory(); sortCriteria.value = advanceGeneralDirectorySort(sortCriteria.value, selectedSort.value);
}; };
onMounted(() => { onMounted(() => {
@@ -66,8 +80,15 @@ onMounted(() => {
<tbody> <tbody>
<tr> <tr>
<td> <td>
<br /><button class="legacy-button" type="button" @click="router.push('/')"> <br />
닫기 <button class="legacy-button" type="button" @click="router.push('/')"> 닫기</button>
<button
class="legacy-button"
type="button"
:aria-busy="loading || undefined"
@click="loadDirectory"
>
</button> </button>
</td> </td>
</tr> </tr>
@@ -75,11 +96,11 @@ onMounted(() => {
<td> <td>
<LegacySortControls <LegacySortControls
control-id="viewType" control-id="viewType"
:model-value="sort" :model-value="selectedSort"
:options="sortOptions" :options="sortOptions"
:busy="loading" :busy="loading"
@update:model-value="updateSort" @update:model-value="updateSort"
@submit="loadDirectory" @submit="sortByHeader(selectedSort)"
/> />
</td> </td>
</tr> </tr>
@@ -87,7 +108,12 @@ onMounted(() => {
</table> </table>
<p v-if="error" class="directory-error" role="alert">{{ error }}</p> <p v-if="error" class="directory-error" role="alert">{{ error }}</p>
<GeneralDirectoryTable :generals="generals" :loading="loading" :active-sort="sort" @sort="sortByHeader" /> <GeneralDirectoryTable
:generals="generals"
:loading="loading"
:sort-criteria="sortCriteria"
@sort="sortByHeader"
/>
<table class="directory-table title-table legacy-bg0"> <table class="directory-table title-table legacy-bg0">
<tbody> <tbody>
+17 -8
View File
@@ -53,7 +53,13 @@ onMounted(async () => {
<template> <template>
<main class="global-page legacy-bg0"> <main class="global-page legacy-bg0">
<header class="legacy-title"> <header class="legacy-title">
<button type="button" @click="goBack">돌아가기</button><strong>중원 정보</strong> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="goBack"
>
돌아가기</button
><strong>중원 정보</strong>
</header> </header>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<section v-if="data" class="section"> <section v-if="data" class="section">
@@ -147,7 +153,15 @@ onMounted(async () => {
</div> </div>
</div> </div>
</section> </section>
<footer class="legacy-title footer"><button type="button" @click="goBack">돌아가기</button></footer> <footer class="legacy-title footer">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="goBack"
>
돌아가기
</button>
</footer>
<button class="legacy-compat-button" type="button" tabindex="-1" aria-hidden="true" /> <button class="legacy-compat-button" type="button" tabindex="-1" aria-hidden="true" />
</main> </main>
</template> </template>
@@ -178,15 +192,10 @@ onMounted(async () => {
line-height: 32px; line-height: 32px;
} }
.legacy-title button { .legacy-title button {
--legacy-button-height: 32px;
position: absolute; position: absolute;
inset: 0 auto 0 0; inset: 0 auto 0 0;
width: 88px; width: 88px;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
background: #087f45;
color: #fff;
font-weight: 700;
cursor: pointer;
} }
.legacy-compat-button { .legacy-compat-button {
display: none; display: none;
+5 -4
View File
@@ -449,10 +449,12 @@ onMounted(() => {
<template> <template>
<header class="top-back-bar legacy-bg0"> <header class="top-back-bar legacy-bg0">
<RouterLink class="top-button legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink> <RouterLink class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height" to="/"
>돌아가기</RouterLink
>
<strong>유산 관리</strong> <strong>유산 관리</strong>
<button <button
class="top-button legacy-button legacy-button--navigation" class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button" type="button"
:disabled="loading" :disabled="loading"
@click="loadStatus" @click="loadStatus"
@@ -786,8 +788,7 @@ onMounted(() => {
} }
.top-button { .top-button {
height: 32px; --legacy-button-height: 32px;
min-height: 32px;
padding: 2px 8px; padding: 2px 8px;
text-decoration: none; text-decoration: none;
} }
@@ -338,7 +338,13 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0"> <main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar"> <header class="legacy-top-bar">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="legacy-button legacy-button--navigation" type="button" @click="navigate">돌아가기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="navigate"
>
돌아가기
</button>
</RouterLink> </RouterLink>
<div></div> <div></div>
<h1>국가 베팅장</h1> <h1>국가 베팅장</h1>
@@ -395,7 +401,7 @@ onMounted(() => {
<div>사용 포인트: {{ usedAmount.toLocaleString('ko-KR') }}</div> <div>사용 포인트: {{ usedAmount.toLocaleString('ko-KR') }}</div>
<div>대상: {{ selectionLabel(selectedKey) }}</div> <div>대상: {{ selectionLabel(selectedKey) }}</div>
<input v-model.number="amount" aria-label="베팅 금액" type="number" min="10" max="1000" step="10" /> <input v-model.number="amount" aria-label="베팅 금액" type="number" min="10" max="1000" step="10" />
<button type="submit" :disabled="submitting">베팅</button> <button class="legacy-button legacy-button--primary" type="submit" :disabled="submitting">베팅</button>
</form> </form>
<div class="payout-table"> <div class="payout-table">
@@ -475,33 +481,10 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.legacy-nav-button, .legacy-top-bar .legacy-button {
.betting-form button { --legacy-button-height: 32px;
height: 32px;
border: 0;
background: #00582c;
color: #fff;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
} }
.legacy-nav-button {
display: grid;
place-items: center;
margin-right: 2px;
}
.legacy-nav-button:hover,
.legacy-nav-button:focus,
.betting-form button:hover,
.betting-form button:focus {
filter: brightness(1.18);
}
.legacy-nav-button:focus-visible,
.betting-form button:focus-visible,
.betting-candidate:focus-visible, .betting-candidate:focus-visible,
.betting-item:focus-visible { .betting-item:focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
@@ -662,9 +645,8 @@ onMounted(() => {
padding-top: 20px; padding-top: 20px;
} }
.betting-footer .legacy-nav-button { .betting-footer .legacy-button {
width: 90px; width: 90px;
height: 35.5px;
} }
.betting-notice, .betting-notice,
@@ -536,14 +536,25 @@ onBeforeUnmount(() => {
<main class="general-page legacy-bg0"> <main class="general-page legacy-bg0">
<header class="top-bar"> <header class="top-bar">
<span class="left-actions"> <span class="left-actions">
<button class="top-button nation-button" @click="router.push('/')">돌아가기</button> <button
<button class="top-button nation-button" :disabled="loading" @click="load">갱신</button> class="legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button"
@click="router.push('/')"
>
돌아가기
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button"
:disabled="loading"
@click="load"
>
갱신
</button>
</span> </span>
<strong>세력 장수</strong> <strong>세력 장수</strong>
<span class="right-actions"> <span class="right-actions">
<span class="dropdown"> <span class="dropdown">
<button <button
class="top-button mode-button" class="legacy-button legacy-button--primary legacy-button--fixed-height top-button mode-button"
:aria-expanded="viewMenuOpen" :aria-expanded="viewMenuOpen"
@click=" @click="
viewMenuOpen = !viewMenuOpen; viewMenuOpen = !viewMenuOpen;
@@ -576,7 +587,7 @@ onBeforeUnmount(() => {
</span> </span>
<span class="dropdown"> <span class="dropdown">
<button <button
class="top-button columns-button" class="legacy-button legacy-button--info legacy-button--fixed-height top-button columns-button"
:aria-expanded="columnMenuOpen" :aria-expanded="columnMenuOpen"
@click=" @click="
columnMenuOpen = !columnMenuOpen; columnMenuOpen = !columnMenuOpen;
@@ -850,45 +861,30 @@ onBeforeUnmount(() => {
right: 0; right: 0;
} }
.top-button { .top-button {
--legacy-button-height: 32px;
display: inline-flex; display: inline-flex;
width: 89px; width: 89px;
height: 32px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
border: 0;
border-right: 1px solid #151515;
border-radius: 3px;
color: #fff;
font-size: 14px; font-size: 14px;
font-weight: 700;
text-decoration: none; text-decoration: none;
cursor: pointer;
} }
.nation-button { .nation-button {
background: #006c48; --legacy-button-bg: #006c48;
} --legacy-button-border: #005d3e;
.nation-button:hover {
background: #00855a;
} }
.mode-button { .mode-button {
background: #375a7f; --legacy-button-bg: #375a7f;
border-bottom: 0 solid #325172; --legacy-button-border: #325172;
}
.mode-button:hover,
.mode-button[aria-expanded='true'],
.mode-button:active {
border-bottom-width: 3px;
} }
.mode-button, .mode-button,
.columns-button { .columns-button {
width: 90px; width: 90px;
} }
.columns-button { .columns-button {
background: #3297cf; --legacy-button-bg: #3297cf;
} --legacy-button-border: #2b80b0;
.columns-button:hover {
filter: brightness(1.12);
} }
.dropdown { .dropdown {
position: relative; position: relative;
@@ -173,7 +173,9 @@ onMounted(() => void loadStratFinan());
<template> <template>
<main id="finance-container" class="page-finance"> <main id="finance-container" class="page-finance">
<nav class="top-back-bar"> <nav class="top-back-bar">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink> <RouterLink class="legacy-button legacy-button--navigation legacy-button--fixed-height" to="/"
>돌아가기</RouterLink
>
<span /> <span />
<strong>내무부</strong> <strong>내무부</strong>
<span /> <span />
@@ -224,7 +226,7 @@ onMounted(() => void loadStratFinan());
<span> <span>
<button <button
v-if="canEdit && !editingNationMsg" v-if="canEdit && !editingNationMsg"
class="message-button" class="legacy-button legacy-button--secondary message-button"
type="button" type="button"
@click="enableEditNationMsg" @click="enableEditNationMsg"
> >
@@ -232,7 +234,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingNationMsg" v-if="canEdit && editingNationMsg"
class="policy-submit" class="legacy-button legacy-button--primary policy-submit"
type="button" type="button"
@click="saveNationMsg" @click="saveNationMsg"
> >
@@ -240,7 +242,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingNationMsg" v-if="canEdit && editingNationMsg"
class="policy-cancel" class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="rollbackNationMsg" @click="rollbackNationMsg"
> >
@@ -249,24 +251,15 @@ onMounted(() => void loadStratFinan());
</span> </span>
</header> </header>
<div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" /> <div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" />
<LegacyHtmlEditor <LegacyHtmlEditor v-else v-model="nationMsgDraft" :max-length="16384" aria-label="국가 방침" />
v-else
v-model="nationMsgDraft"
:max-length="16384"
aria-label="국가 방침"
/>
</section> </section>
<section <section id="scout-message-form" class="message-form" :class="{ 'message-form--editing': editingScoutMsg }">
id="scout-message-form"
class="message-form"
:class="{ 'message-form--editing': editingScoutMsg }"
>
<header class="green-header"> <header class="green-header">
<span>임관 권유</span> <span>임관 권유</span>
<span> <span>
<button <button
v-if="canEdit && !editingScoutMsg" v-if="canEdit && !editingScoutMsg"
class="message-button" class="legacy-button legacy-button--secondary message-button"
type="button" type="button"
@click="enableEditScoutMsg" @click="enableEditScoutMsg"
> >
@@ -274,7 +267,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingScoutMsg" v-if="canEdit && editingScoutMsg"
class="policy-submit" class="legacy-button legacy-button--primary policy-submit"
type="button" type="button"
@click="saveScoutMsg" @click="saveScoutMsg"
> >
@@ -282,7 +275,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingScoutMsg" v-if="canEdit && editingScoutMsg"
class="policy-cancel" class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="rollbackScoutMsg" @click="rollbackScoutMsg"
> >
@@ -292,12 +285,7 @@ onMounted(() => void loadStratFinan());
</header> </header>
<div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div> <div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
<div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" /> <div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" />
<LegacyHtmlEditor <LegacyHtmlEditor v-else v-model="scoutMsgDraft" :max-length="1000" aria-label="임관 권유" />
v-else
v-model="scoutMsgDraft"
:max-length="1000"
aria-label="임관 권유"
/>
</section> </section>
<div class="finance-title">예산&amp;정책</div> <div class="finance-title">예산&amp;정책</div>
@@ -362,10 +350,17 @@ onMounted(() => void loadStratFinan());
<input v-model.number="policy.rate" aria-label="세율" type="number" min="5" max="30" /><span <input v-model.number="policy.rate" aria-label="세율" type="number" min="5" max="30" /><span
>%</span >%</span
> >
<button v-if="canEdit" class="policy-submit" type="button" @click="setRate">변경</button>
<button <button
v-if="canEdit" v-if="canEdit"
class="policy-cancel" class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="setRate"
>
변경
</button>
<button
v-if="canEdit"
class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="policy.rate = oldPolicy.rate" @click="policy.rate = oldPolicy.rate"
> >
@@ -379,10 +374,17 @@ onMounted(() => void loadStratFinan());
<input v-model.number="policy.bill" aria-label="지급률" type="number" min="20" max="200" /><span <input v-model.number="policy.bill" aria-label="지급률" type="number" min="20" max="200" /><span
>%</span >%</span
> >
<button v-if="canEdit" class="policy-submit" type="button" @click="setBill">변경</button>
<button <button
v-if="canEdit" v-if="canEdit"
class="policy-cancel" class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="setBill"
>
변경
</button>
<button
v-if="canEdit"
class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="policy.bill = oldPolicy.bill" @click="policy.bill = oldPolicy.bill"
> >
@@ -400,10 +402,17 @@ onMounted(() => void loadStratFinan());
min="1" min="1"
max="99" max="99"
/><span></span> /><span></span>
<button v-if="canEdit" class="policy-submit" type="button" @click="setSecretLimit">변경</button>
<button <button
v-if="canEdit" v-if="canEdit"
class="policy-cancel" class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="setSecretLimit"
>
변경
</button>
<button
v-if="canEdit"
class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="policy.secretLimit = oldPolicy.secretLimit" @click="policy.secretLimit = oldPolicy.secretLimit"
> >
@@ -438,7 +447,7 @@ onMounted(() => void loadStratFinan());
<input v-for="index in 4" :key="`compat-input-${index}`" type="hidden" /> <input v-for="index in 4" :key="`compat-input-${index}`" type="hidden" />
</div> </div>
<footer class="bottom-bar"> <footer class="bottom-bar">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink> <RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
</footer> </footer>
</template> </template>
</main> </main>
@@ -472,32 +481,8 @@ onMounted(() => void loadStratFinan());
.top-back-bar button { .top-back-bar button {
grid-column: 5; grid-column: 5;
} }
.legacy-button, .top-back-bar .legacy-button {
button { --legacy-button-height: 32px;
box-sizing: border-box;
border: 1px solid #00502a;
border-radius: 4px;
padding: 5.25px 10.5px;
color: #fff;
background: #00582c;
font: inherit;
line-height: 21px;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.message-button,
.policy-cancel {
border-color: #6c757d;
background: #6c757d;
}
.policy-submit {
border-color: #325172;
background: #375a7f;
}
button:hover,
.legacy-button:hover {
filter: brightness(1.2);
} }
button:focus-visible, button:focus-visible,
.legacy-button:focus-visible, .legacy-button:focus-visible,
+4 -10
View File
@@ -355,13 +355,14 @@ const submitPriority = async (section: PrioritySectionKey) => {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`; error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
} }
}; };
</script> </script>
<template> <template>
<main id="npc-policy-page" class="npc-page"> <main id="npc-policy-page" class="npc-page">
<nav class="top-back-bar legacy-bg0"> <nav class="top-back-bar legacy-bg0">
<RouterLink class="back-button" to="/">돌아가기</RouterLink> <RouterLink class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button" to="/"
>돌아가기</RouterLink
>
<strong>NPC 정책</strong> <strong>NPC 정책</strong>
</nav> </nav>
@@ -579,21 +580,14 @@ const submitPriority = async (section: PrioritySectionKey) => {
} }
.back-button { .back-button {
--legacy-button-height: 32px;
position: absolute; position: absolute;
inset: 0 auto 0 0; inset: 0 auto 0 0;
width: 88px; width: 88px;
color: #fff;
background: #087f45;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
font-weight: 700;
line-height: 30px;
text-decoration: none; text-decoration: none;
} }
.back-button:hover,
.back-button:focus-visible { .back-button:focus-visible {
background: #0a9960;
outline: 2px solid #fff; outline: 2px solid #fff;
outline-offset: -2px; outline-offset: -2px;
} }
+7 -4
View File
@@ -209,11 +209,15 @@ onMounted(() => {
<template> <template>
<main id="container" class="pageVote bg0"> <main id="container" class="pageVote bg0">
<header class="back_bar bg0"> <header class="back_bar bg0">
<button class="legacy-button legacy-button--navigation back_btn" type="button" @click="router.push('/')"> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
type="button"
@click="router.push('/')"
>
닫기 닫기
</button> </button>
<button <button
class="legacy-button legacy-button--navigation reload_btn" class="legacy-button legacy-button--navigation legacy-button--fixed-height reload_btn"
type="button" type="button"
:disabled="loading" :disabled="loading"
@click="reloadVote" @click="reloadVote"
@@ -453,8 +457,7 @@ onMounted(() => {
.back_btn, .back_btn,
.reload_btn { .reload_btn {
height: 32px; --legacy-button-height: 32px;
min-height: 32px;
margin-right: 2px; margin-right: 2px;
font-weight: 600; font-weight: 600;
} }
+18 -9
View File
@@ -190,10 +190,16 @@ const start = async () => {
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" /> <TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
<section class="toolbar bg0"> <section class="toolbar bg0">
<button type="button" @click="load">갱신</button> <button
class="legacy-button legacy-button--secondary legacy-button--fixed-height"
type="button"
@click="load"
>
갱신
</button>
<button <button
type="button" type="button"
class="join-button" class="legacy-button legacy-button--navigation legacy-button--fixed-height join-button"
:disabled="snapshot?.state?.stage !== 1 || isParticipant" :disabled="snapshot?.state?.stage !== 1 || isParticipant"
@click="join" @click="join"
> >
@@ -382,7 +388,9 @@ const start = async () => {
<input type="hidden" name="tournamentAction" value="join" /> <input type="hidden" name="tournamentAction" value="join" />
<footer class="tournament-footer bg0"> <footer class="tournament-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="legacy-button legacy-button--navigation close-button" type="button" @click="navigate">
닫기
</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
@@ -439,8 +447,8 @@ const start = async () => {
padding: 1px; padding: 1px;
} }
.toolbar button { .toolbar button {
--legacy-button-height: 44px;
min-width: 72px; min-width: 72px;
height: 44px;
padding: 10px 16px; padding: 10px 16px;
font-size: 14px; font-size: 14px;
} }
@@ -451,7 +459,7 @@ const start = async () => {
min-height: 32px; min-height: 32px;
padding: 5px; padding: 5px;
} }
button { button:not(.legacy-button) {
height: 35.5px; height: 35.5px;
margin: 0 2px; margin: 0 2px;
border: 1px solid #666; border: 1px solid #666;
@@ -460,16 +468,17 @@ button {
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
} }
button:hover, button:not(.legacy-button):hover,
button:focus { button:not(.legacy-button):focus {
filter: brightness(1.25); filter: brightness(1.25);
} }
button:focus-visible { button:not(.legacy-button):focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
outline-offset: 1px; outline-offset: 1px;
} }
.join-button { .join-button {
background: #8a5b13; --legacy-button-bg: #8a5b13;
--legacy-button-border: #704a0f;
} }
.operator-row span { .operator-row span {
color: orange; color: orange;
+3 -4
View File
@@ -179,14 +179,14 @@ onMounted(() => {
<main id="container" class="legacy-troop-page"> <main id="container" class="legacy-troop-page">
<header class="topBackBar bg0"> <header class="topBackBar bg0">
<button <button
class="legacy-button legacy-button--navigation legacyNavButton backLink" class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton backLink"
type="button" type="button"
@click="router.push('/')" @click="router.push('/')"
> >
돌아가기 돌아가기
</button> </button>
<button <button
class="legacy-button legacy-button--navigation legacyNavButton reloadButton" class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton reloadButton"
type="button" type="button"
:disabled="loading" :disabled="loading"
@click="refresh" @click="refresh"
@@ -395,8 +395,7 @@ onMounted(() => {
} }
.legacyNavButton { .legacyNavButton {
height: 32px; --legacy-button-height: 32px;
min-height: 32px;
margin-right: 2px; margin-right: 2px;
font-weight: 600; font-weight: 600;
} }
+12 -15
View File
@@ -137,11 +137,18 @@ onMounted(async () => {
<main id="yearbook-container" class="yearbook-page legacy-bg0"> <main id="yearbook-container" class="yearbook-page legacy-bg0">
<header class="yearbook-title legacy-bg0"> <header class="yearbook-title legacy-bg0">
<strong> </strong> <strong> </strong>
<button class="legacy-button close-button" type="button" @click="closePage"> 닫기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closePage"
>
닫기
</button>
<span class="settings-menu"> <span class="settings-menu">
<button <button
class="legacy-button legacy-button--navigation" class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button" type="button"
:aria-expanded="settingsOpen"
@click="settingsOpen = !settingsOpen" @click="settingsOpen = !settingsOpen"
> >
설정 설정
@@ -235,7 +242,7 @@ onMounted(async () => {
</section> </section>
<footer class="yearbook-footer"> <footer class="yearbook-footer">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button> <button class="legacy-button legacy-button--navigation" type="button" @click="closePage"> 닫기</button>
</footer> </footer>
<div class="dropdown-compat-buttons" aria-hidden="true"> <div class="dropdown-compat-buttons" aria-hidden="true">
<button type="button" tabindex="-1" /><button type="button" tabindex="-1" /> <button type="button" tabindex="-1" /><button type="button" tabindex="-1" />
@@ -286,14 +293,14 @@ onMounted(async () => {
} }
.yearbook-title .close-button { .yearbook-title .close-button {
left: 0; left: 0;
height: 32px; --legacy-button-height: 32px;
} }
.settings-menu { .settings-menu {
right: 0; right: 0;
height: 32px; height: 32px;
} }
.settings-menu > .legacy-button { .settings-menu > .legacy-button {
height: 32px; --legacy-button-height: 32px;
} }
.settings-item { .settings-item {
position: absolute; position: absolute;
@@ -393,16 +400,6 @@ onMounted(async () => {
.dropdown-compat-buttons { .dropdown-compat-buttons {
display: none; display: none;
} }
.year-selector .legacy-button {
border: 0;
background: #444;
}
.settings-menu > .legacy-button,
.yearbook-title .close-button {
border: 0;
background: #00582c;
}
.log-content { .log-content {
min-height: 72px; min-height: 72px;
padding: 1px 0; padding: 1px 0;
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
advanceGeneralDirectorySort,
sortGeneralDirectory,
type GeneralDirectorySortable,
} from '../src/utils/generalDirectorySort.ts';
type Row = GeneralDirectorySortable & { id: number };
const row = (id: number, name: string, leadership: number, strength: number): Row => ({
id,
name,
nationId: id,
leadership,
strength,
intelligence: 0,
experience: 0,
dedication: 0,
officerLevel: 0,
killturn: 0,
refreshScoreTotal: 0,
personality: { key: '' },
specialDomestic: { key: '' },
specialWar: { key: '' },
age: 0,
npcState: 0,
});
void describe('general directory client sorting', () => {
void it('cycles each column through descending, ascending, and reset', () => {
const descending = advanceGeneralDirectorySort([], 2);
assert.deepEqual(descending, [{ key: 2, direction: 'descending' }]);
const ascending = advanceGeneralDirectorySort(descending, 2);
assert.deepEqual(ascending, [{ key: 2, direction: 'ascending' }]);
assert.deepEqual(advanceGeneralDirectorySort(ascending, 2), []);
});
void it('keeps the previous descending key as a tie-breaker for a later ascending key', () => {
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
const leadership = advanceGeneralDirectorySort([], 2);
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
assert.deepEqual(
sortGeneralDirectory(source, strengthAscending).map(({ id }) => id),
[3, 2, 1]
);
});
void it('removes only the reset column and restores the older stable ordering', () => {
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
const leadership = advanceGeneralDirectorySort([], 2);
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
const resetStrength = advanceGeneralDirectorySort(strengthAscending, 3);
assert.deepEqual(resetStrength, leadership);
assert.deepEqual(
sortGeneralDirectory(source, resetStrength).map(({ id }) => id),
[2, 3, 1]
);
});
void it('sorts Korean names in both directions without mutating the source', () => {
const source = [row(1, '조조', 0, 0), row(2, '가후', 0, 0), row(3, '유비', 0, 0)];
const descending = advanceGeneralDirectorySort([], 0);
const ascending = advanceGeneralDirectorySort(descending, 0);
assert.deepEqual(
sortGeneralDirectory(source, descending).map(({ name }) => name),
['조조', '유비', '가후']
);
assert.deepEqual(
sortGeneralDirectory(source, ascending).map(({ name }) => name),
['가후', '유비', '조조']
);
assert.deepEqual(
source.map(({ id }) => id),
[1, 2, 3]
);
});
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, it } from 'node:test';
const source = (relativePath: string) => readFile(path.resolve(import.meta.dirname, '../src', relativePath), 'utf8');
void describe('shared Lumen button family', () => {
void it('keeps fixed-height state compensation in the shared control layer', async () => {
const css = await source('assets/styles/legacy-controls.css');
assert.match(css, /\.legacy-button\.legacy-button--fixed-height\s*\{/u);
assert.match(css, /height:\s*calc\(var\(--legacy-button-height\) - 1px\)/u);
assert.match(css, /height:\s*calc\(var\(--legacy-button-height\) - 2px\)/u);
});
void it('connects every reported control to a semantic Lumen variant', async () => {
const [nationGenerals, tournamentHeader, reservedEditor] = await Promise.all([
source('views/NationGeneralsView.vue'),
source('components/tournament/TournamentPageHeader.vue'),
source('components/command/ReservedCommandEditor.vue'),
]);
assert.match(
nationGenerals,
/legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button/u
);
assert.match(
nationGenerals,
/legacy-button legacy-button--primary legacy-button--fixed-height top-button mode-button/u
);
assert.match(
nationGenerals,
/legacy-button legacy-button--info legacy-button--fixed-height top-button columns-button/u
);
for (const label of ['돌아가기', '갱신', '보기 모드⌄', '열 선택⌄']) {
assert.match(nationGenerals, new RegExp(label, 'u'));
}
for (const label of ['토너먼트', '베팅장', '창 닫기']) {
assert.match(tournamentHeader, new RegExp(`legacy-button[\\s\\S]{0,260}${label}`, 'u'));
}
assert.match(reservedEditor, /legacy-button legacy-button--info legacy-button--fixed-height select-command/u);
assert.match(reservedEditor, / /u);
});
void it('uses the same family for audited Ref TopBackBar navigation controls', async () => {
const files = [
'views/AuctionView.vue',
'views/BattleCenterView.vue',
'views/BoardView.vue',
'views/ChiefCenterView.vue',
'views/GlobalInfoView.vue',
'views/InheritView.vue',
'views/NationBettingView.vue',
'views/NationStratFinanView.vue',
'views/NpcControlView.vue',
'views/SurveyView.vue',
'views/TroopView.vue',
'views/YearbookView.vue',
];
const contents = await Promise.all(files.map(source));
for (const [index, content] of contents.entries()) {
assert.match(
content,
/legacy-button legacy-button--navigation/u,
`${files[index]} must opt its raised navigation control into the shared family`
);
}
});
});
+10 -6
View File
@@ -31,9 +31,12 @@ The Ref-style directory pages share a second, deliberately compact control
family through `LegacySortControls.vue`. Its `.legacy-sort-*` rules own the family through `LegacySortControls.vue`. Its `.legacy-sort-*` rules own the
explicit dark select/option palette, the raised submit button, and the explicit dark select/option palette, the raised submit button, and the
focus/active states for sortable table headers. A page supplies only the focus/active states for sortable table headers. A page supplies only the
available legacy sort keys, their fixed directions, and placement. Columns available sort keys and placement. NPC·암행부·세력도시는 Ref의 고정 방향을
without an unambiguous legacy sort key remain plain headers rather than 유지합니다. 장수 일람은 사용자 조작 계약에 따라 로드한 snapshot 위에서
inventing a new ordering contract. `내림차순 → 오름차순 → 해제`를 순환하고, 여러 열의 방향과 우선순위를 scoped
SFC indicator로 표시합니다. 장수 일람의 성격·특기·부상 설명은 많은 행에서
eager popup instance를 만들지 않는 `DirectoryTooltip.vue` scoped CSS가 소유하며,
mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다.
## Button composition ## Button composition
@@ -77,10 +80,11 @@ validating one representative button.
The primitive does not set a fixed `min-height`: Ref's 35.5px default height is The primitive does not set a fixed `min-height`: Ref's 35.5px default height is
the result of line-height, padding, and the 4px edge, so it naturally becomes the result of line-height, padding, and the 4px edge, so it naturally becomes
34.5px/33.5px while the 1px/2px top margin keeps the bottom coordinate fixed. 34.5px/33.5px while the 1px/2px top margin keeps the bottom coordinate fixed.
Only a fixed-height owner such as the mobile bottom bar supplies explicit A fixed row opts into `.legacy-button--fixed-height` and supplies only
45px/44px/43px state compensation. `--legacy-button-height`; the shared layer derives the hover/active heights so
every owner keeps the same bottom-coordinate contract.
Only layout belongs in the SFC: width, grid column, fixed-height compensation, Only layout belongs in the SFC: width, grid column, the fixed height variable,
margins required by the page, and breakpoint-specific placement. Color base margins required by the page, and breakpoint-specific placement. Color base
variables may be supplied by the owner for dynamic nation/scenario colors, but variables may be supplied by the owner for dynamic nation/scenario colors, but
border construction, font weight, hover/focus/active, and disabled presentation border construction, font weight, hover/focus/active, and disabled presentation
@@ -190,6 +190,84 @@ test.beforeEach(async ({ page }) => {
await installFixture(page); await installFixture(page);
}); });
test('tournament controls share fixed Lumen state geometry on desktop and mobile', async ({ page }, testInfo) => {
const evidence: Record<string, unknown> = {};
for (const viewport of [
{ width: 1024, height: 768 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto(`${gameUrl}/che/tournament`);
await expect(page.getByText('삼모전 토너먼트')).toBeVisible();
const controls = [
page.getByRole('tab', { name: '토너먼트' }),
page.getByRole('tab', { name: '베팅장' }),
page.getByRole('button', { name: '창 닫기' }).first(),
page.getByRole('button', { name: '갱신' }),
];
const viewportEvidence: Record<string, unknown> = {};
for (const control of controls) {
const label = (await control.textContent())?.trim() ?? 'unknown';
await control.scrollIntoViewIfNeeded();
await expect(control).toHaveClass(/legacy-button--fixed-height/u);
const measure = () =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
borderRadius: style.borderRadius,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
};
});
await page.mouse.move(viewport.width - 1, viewport.height - 1);
const base = await measure();
expect(base).toMatchObject({
height: 44,
marginTop: '0px',
borderBottomWidth: '4px',
borderRadius: '5.25px',
fontSize: '14px',
});
expect(base.fontFamily).toContain('Pretendard');
await control.hover();
const hover = await measure();
expect(hover).toMatchObject({ height: 43, marginTop: '1px', borderBottomWidth: '3px' });
expect(hover.bottom).toBeCloseTo(base.bottom, 2);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} tournament control is not measurable`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await measure();
expect(active).toMatchObject({ height: 42, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(base.bottom, 2);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await page.mouse.up();
viewportEvidence[label] = { default: base, hover, active };
}
evidence[`${viewport.width}x${viewport.height}`] = viewportEvidence;
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/core-tournament-buttons-${viewport.width}.png`,
fullPage: true,
});
}
}
await testInfo.attach('tournament-button-geometry', {
body: JSON.stringify(evidence, null, 2),
contentType: 'application/json',
});
});
test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => { test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => {
await page.setViewportSize({ width: 2200, height: 1000 }); await page.setViewportSize({ width: 2200, height: 1000 });
await page.goto(`${gameUrl}/che/tournament`); await page.goto(`${gameUrl}/che/tournament`);
@@ -210,6 +288,9 @@ test('tournament keeps the legacy 2000px bracket and 250px group geometry', asyn
groupWidth: groupTable.getBoundingClientRect().width, groupWidth: groupTable.getBoundingClientRect().width,
titleHeight: title.getBoundingClientRect().height, titleHeight: title.getBoundingClientRect().height,
refreshHeight: refresh.getBoundingClientRect().height, refreshHeight: refresh.getBoundingClientRect().height,
refreshBottom: refresh.getBoundingClientRect().bottom,
refreshMarginTop: getComputedStyle(refresh).marginTop,
refreshEdge: getComputedStyle(refresh).borderBottomWidth,
refreshRadius: getComputedStyle(refresh).borderRadius, refreshRadius: getComputedStyle(refresh).borderRadius,
fontFamily: style.fontFamily, fontFamily: style.fontFamily,
fontSize: style.fontSize, fontSize: style.fontSize,
@@ -222,7 +303,9 @@ test('tournament keeps the legacy 2000px bracket and 250px group geometry', asyn
candidateWidth: 125, candidateWidth: 125,
groupWidth: 250, groupWidth: 250,
titleHeight: 55.6875, titleHeight: 55.6875,
refreshHeight: 35.5, refreshHeight: 44,
refreshMarginTop: '0px',
refreshEdge: '4px',
refreshRadius: '5.25px', refreshRadius: '5.25px',
fontSize: '14px', fontSize: '14px',
}); });
@@ -231,12 +314,39 @@ test('tournament keeps the legacy 2000px bracket and 250px group geometry', asyn
if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true }); if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true });
const refresh = page.getByRole('button', { name: '갱신' }); const refresh = page.getByRole('button', { name: '갱신' });
const before = await refresh.evaluate((element) => getComputedStyle(element).filter);
await refresh.hover(); await refresh.hover();
const hover = await refresh.evaluate((element) => getComputedStyle(element).filter); const hover = await refresh.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
filter: style.filter,
};
});
expect(hover).toMatchObject({ height: 43, marginTop: '1px', borderBottomWidth: '3px', filter: 'none' });
expect(hover.bottom).toBeCloseTo(geometry.refreshBottom, 2);
const box = await refresh.boundingBox();
if (!box) throw new Error('tournament refresh is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await refresh.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
};
});
expect(active).toMatchObject({ height: 42, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(geometry.refreshBottom, 2);
await page.mouse.up();
await refresh.focus(); await refresh.focus();
await expect(refresh).toBeFocused(); await expect(refresh).toBeFocused();
expect(hover).not.toBe(before);
}); });
test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => { test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => {