merge: 최신 main을 3분 리셋 지원에 반영한다
This commit is contained in:
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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,7 +485,10 @@ 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
|
||||||
|
.locator('#secret-general-list .turns')
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,8 +511,21 @@ 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
|
||||||
|
v-show="mapOptionsOpen"
|
||||||
|
:id="mapOptionsMenuId"
|
||||||
|
class="map-options-menu"
|
||||||
|
role="group"
|
||||||
|
aria-label="지도 옵션 메뉴"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="map-toggle"
|
||||||
|
:class="{ active: showCityName }"
|
||||||
|
:aria-pressed="showCityName"
|
||||||
|
@click="mapStore.toggleCityName"
|
||||||
|
>
|
||||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -504,11 +533,23 @@ const selectCity = (cityId: number) => {
|
|||||||
class="map-toggle map-toggle-single-tap"
|
class="map-toggle map-toggle-single-tap"
|
||||||
:class="{ active: singleTapNavigation }"
|
:class="{ active: singleTapNavigation }"
|
||||||
:aria-pressed="singleTapNavigation"
|
:aria-pressed="singleTapNavigation"
|
||||||
@click.stop="toggleSingleTapNavigation"
|
@click="toggleSingleTapNavigation"
|
||||||
>
|
>
|
||||||
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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);
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">예산&정책</div>
|
<div class="finance-title">예산&정책</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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user