merge: 최신 main을 개인 기록 시각 글자 크기 보정에 통합
This commit is contained in:
@@ -43,6 +43,7 @@ const inputOptions = {
|
||||
cities: [
|
||||
{ value: 1, label: '업 (아국)' },
|
||||
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
||||
{ value: 3, label: '단양 (오)' },
|
||||
],
|
||||
nations: [
|
||||
{ value: 1, label: '아국', color: '#008000' },
|
||||
@@ -52,6 +53,18 @@ const inputOptions = {
|
||||
{ value: 1, label: '장수 (아국 · 업)' },
|
||||
{ value: 2, label: '관우 (아국 · 업)' },
|
||||
],
|
||||
generalTargets: {
|
||||
che_포상: [
|
||||
{ value: 1, label: '장수 (아국 · 업)' },
|
||||
{ value: 2, label: '관우 (아국 · 업)' },
|
||||
{ value: 3, label: '여포NPC (아국 · 업)' },
|
||||
],
|
||||
che_몰수: [
|
||||
{ value: 1, label: '장수 (아국 · 업)' },
|
||||
{ value: 2, label: '관우 (아국 · 업)' },
|
||||
{ value: 3, label: '여포NPC (아국 · 업)' },
|
||||
],
|
||||
},
|
||||
crewTypes: [{ value: 1100, label: '보병' }],
|
||||
armTypes: [{ value: 1, label: '보병' }],
|
||||
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
|
||||
@@ -182,6 +195,27 @@ const commandTable = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '군사',
|
||||
values: [
|
||||
{
|
||||
key: 'che_출병',
|
||||
name: '출병',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
{
|
||||
key: 'destCityId',
|
||||
label: '대상 도시',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'cities',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
nation: [
|
||||
{
|
||||
@@ -312,6 +346,7 @@ const generalContext = {
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||
turnTime: '2026-08-17T12:00:00.000Z',
|
||||
},
|
||||
city: {
|
||||
id: 1,
|
||||
@@ -366,8 +401,8 @@ const chiefCenter = {
|
||||
maxTurns: 12,
|
||||
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
|
||||
officerLevel,
|
||||
name: officerLevel === 5 ? '장수' : null,
|
||||
npcState: officerLevel === 5 ? 0 : null,
|
||||
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
|
||||
npcState: officerLevel === 8 ? 2 : 0,
|
||||
turnTime: null,
|
||||
revision: 0,
|
||||
turns: turns(12),
|
||||
@@ -483,7 +518,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
if (name === 'turns.getCommandTable') return response(commandTableResponse);
|
||||
if (name === 'nation.getChiefCenter') return response(chiefCenter);
|
||||
if (name === 'turns.reserved.getGeneral')
|
||||
return response({ turns: generalTurns, revision: generalRevision });
|
||||
return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
|
||||
if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision });
|
||||
if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
|
||||
if (name === 'general.getFrontStatus')
|
||||
@@ -520,7 +555,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} };
|
||||
}
|
||||
generalRevision += 1;
|
||||
return response({ ok: true, revision: generalRevision, turns: generalTurns });
|
||||
return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 });
|
||||
}
|
||||
if (name === 'turns.reserved.setNationBulk') {
|
||||
requests.push(body);
|
||||
@@ -561,7 +596,7 @@ test('renders and accepts every Ref strategy command at mobile width', async ({
|
||||
await button.click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance);
|
||||
await expect(form.locator('select option')).toHaveCount(2);
|
||||
await expect(form.locator('select option')).toHaveCount(3);
|
||||
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||
}
|
||||
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') });
|
||||
@@ -709,7 +744,7 @@ test('reserves force move, retirement, and resignation from the user command pic
|
||||
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
|
||||
await forceMoveForm.locator('select').selectOption('2');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행');
|
||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행');
|
||||
|
||||
const serialized = JSON.stringify(requests);
|
||||
expect(serialized).toContain('"action":"che_은퇴","args":{}');
|
||||
@@ -778,6 +813,92 @@ test('shows every Ref chief command in the exact category and command order', as
|
||||
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
|
||||
});
|
||||
|
||||
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto('/che/chief-center');
|
||||
|
||||
const editor = page.locator('[data-command-scope="nation"]:visible');
|
||||
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||||
await expect(editor.locator('.index-column > button')).toHaveCount(12);
|
||||
await expect(editor.locator('.index-column > button').last()).toHaveText('12');
|
||||
await expect(editor.locator('.advanced-actions')).toContainText('선택한 턴을');
|
||||
await expect(editor.locator('.advanced-actions')).toContainText('명령 선택');
|
||||
|
||||
const frame = page.locator('.chief-overview-frame');
|
||||
await expect(frame.locator('.chief-overview-row')).toHaveCount(2);
|
||||
await expect(frame.locator('.overview-turn-index')).toHaveCount(4);
|
||||
for (const gutter of await frame.locator('.overview-turn-index').all()) {
|
||||
await expect(gutter.locator('span').filter({ hasText: /\d+/u })).toHaveText(
|
||||
Array.from({ length: 12 }, (_, index) => String(index + 1))
|
||||
);
|
||||
}
|
||||
await expect(frame.locator('.compact-name')).toHaveCount(8);
|
||||
|
||||
const geometry = await page.locator('.chief-page').evaluate((element) => {
|
||||
const editorElement = element.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
|
||||
const queue = editorElement.querySelector<HTMLElement>('.queue-grid')!;
|
||||
const lastTurn = editorElement.querySelectorAll<HTMLElement>('.action-column > div')[11]!;
|
||||
const actions = editorElement.querySelector<HTMLElement>('.advanced-actions')!;
|
||||
const overviewFrame = element.querySelector<HTMLElement>('.chief-overview-frame')!;
|
||||
const firstOverviewRow = element.querySelector<HTMLElement>('.chief-overview-row')!;
|
||||
const overviewRows = [...element.querySelectorAll<HTMLElement>('.chief-overview-row')];
|
||||
const gutters = [...firstOverviewRow.querySelectorAll<HTMLElement>('.overview-turn-index')];
|
||||
const cards = [...firstOverviewRow.querySelectorAll<HTMLElement>('.chief-card')];
|
||||
const names = [...overviewFrame.querySelectorAll<HTMLElement>('.compact-name')];
|
||||
const frameRect = overviewFrame.getBoundingClientRect();
|
||||
const editorRect = editorElement.getBoundingClientRect();
|
||||
const actionsRect = actions.getBoundingClientRect();
|
||||
return {
|
||||
editorBottom: editorRect.bottom,
|
||||
editorHeight: editorRect.height,
|
||||
queueBottom: queue.getBoundingClientRect().bottom,
|
||||
lastTurnBottom: lastTurn.getBoundingClientRect().bottom,
|
||||
actionsTop: actionsRect.top,
|
||||
actionsBottom: actionsRect.bottom,
|
||||
frameTop: frameRect.top,
|
||||
frameWidth: frameRect.width,
|
||||
rowWidth: firstOverviewRow.getBoundingClientRect().width,
|
||||
rowEdges: overviewRows.map((item) => ({
|
||||
top: item.getBoundingClientRect().top - frameRect.top,
|
||||
bottom: item.getBoundingClientRect().bottom - frameRect.top,
|
||||
})),
|
||||
gutterWidths: gutters.map((item) => item.getBoundingClientRect().width),
|
||||
gutterEdges: gutters.map((item) => ({
|
||||
left: item.getBoundingClientRect().left - frameRect.left,
|
||||
right: item.getBoundingClientRect().right - frameRect.left,
|
||||
})),
|
||||
cardWidths: cards.map((item) => item.getBoundingClientRect().width),
|
||||
namesInsideFrame: names.every((item) => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
return rect.top >= frameRect.top && rect.bottom <= frameRect.bottom && rect.height > 0;
|
||||
}),
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.lastTurnBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
||||
expect(geometry.queueBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
||||
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.editorBottom);
|
||||
expect(geometry.frameTop).toBeGreaterThanOrEqual(geometry.editorBottom);
|
||||
expect(geometry.editorHeight).toBeGreaterThanOrEqual(404);
|
||||
expect(geometry.frameWidth).toBe(500);
|
||||
expect(geometry.rowWidth).toBe(500);
|
||||
expect(geometry.rowEdges).toEqual([
|
||||
{ top: 0, bottom: 155 },
|
||||
{ top: 155, bottom: 310 },
|
||||
]);
|
||||
expect(geometry.gutterWidths).toEqual([12, 12]);
|
||||
expect(geometry.gutterEdges).toEqual([
|
||||
{ left: 0, right: 12 },
|
||||
{ left: 488, right: 500 },
|
||||
]);
|
||||
expect(geometry.cardWidths).toEqual([119, 119, 119, 119]);
|
||||
expect(geometry.namesInsideFrame).toBe(true);
|
||||
expect(geometry.documentOverflow).toBeLessThanOrEqual(0);
|
||||
await page.screenshot({ path: test.info().outputPath('chief-advanced-mobile-500.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
@@ -856,7 +977,9 @@ test('enters general and nation command arguments and sends exact values', async
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계');
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||||
'【허창】에 화계실행'
|
||||
);
|
||||
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
@@ -866,7 +989,13 @@ test('enters general and nation command arguments and sends exact values', async
|
||||
const chiefForm = chiefPicker.getByTestId('command-argument-form');
|
||||
await chiefForm.getByRole('button', { name: '쌀' }).click();
|
||||
await chiefForm.locator('input[type=number]').fill('300');
|
||||
await chiefForm.locator('select').selectOption('2');
|
||||
const chiefTarget = chiefForm.locator('select');
|
||||
await expect(chiefTarget.locator('option')).toHaveText([
|
||||
'장수 (아국 · 업)',
|
||||
'관우 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
]);
|
||||
await chiefTarget.selectOption('3');
|
||||
const geometry = await chiefForm.evaluate((element) => {
|
||||
const row = element.querySelector('.argument-row');
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -879,12 +1008,14 @@ test('enters general and nation command arguments and sends exact values', async
|
||||
};
|
||||
});
|
||||
await chiefPicker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상');
|
||||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
||||
'【여포NPC】 쌀 300 포상'
|
||||
);
|
||||
|
||||
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
||||
expect(JSON.stringify(requests)).toContain('"isGold":false');
|
||||
expect(JSON.stringify(requests)).toContain('"amount":300');
|
||||
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
||||
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
|
||||
|
||||
expect(mapGeometry.width).toBeGreaterThan(650);
|
||||
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
|
||||
@@ -961,7 +1092,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
await infantry.getByRole('button', { name: '절반', exact: true }).click();
|
||||
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('징병');
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||||
'【보병】 3500명 징병'
|
||||
);
|
||||
expect(JSON.stringify(requests)).toContain('"crewType":1100');
|
||||
expect(JSON.stringify(requests)).toContain('"amount":3500');
|
||||
|
||||
@@ -1167,6 +1300,53 @@ test('keeps the entered command visible and reports a server validation error',
|
||||
await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
|
||||
});
|
||||
|
||||
test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('/');
|
||||
|
||||
const editor = page.locator('[data-command-scope="general"]');
|
||||
const status = editor.locator('[data-command-autorun-status]');
|
||||
const firstRow = editor.locator('.action-column > div').first();
|
||||
await expect(status).toHaveText(/자율 행동: 200年 3月 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}까지/u);
|
||||
await expect(firstRow).toContainText('휴식(자율 행동)');
|
||||
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
|
||||
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '출병', exact: true }).click();
|
||||
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
|
||||
await expect(firstRow).toHaveText('【단양】으로 출병');
|
||||
await expect(firstRow).toHaveAttribute('title', /자율 행동/u);
|
||||
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
|
||||
await expect(status).toHaveText(/자율 행동: 200年 3月 · .*까지/u);
|
||||
|
||||
const desktopGeometry = await editor.evaluate((element) => {
|
||||
const statusElement = element.querySelector<HTMLElement>('[data-command-autorun-status]');
|
||||
const row = element.querySelector<HTMLElement>('.action-column > div');
|
||||
if (!statusElement || !row) throw new Error('autonomous command geometry is missing');
|
||||
return {
|
||||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||||
statusWidth: statusElement.getBoundingClientRect().width,
|
||||
editorWidth: element.getBoundingClientRect().width,
|
||||
rowHeight: row.getBoundingClientRect().height,
|
||||
};
|
||||
});
|
||||
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||
expect(desktopGeometry.statusWidth).toBeLessThanOrEqual(desktopGeometry.editorWidth);
|
||||
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20);
|
||||
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-desktop-1200.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await expect(firstRow).toHaveText('【단양】으로 출병');
|
||||
await expect(status).toBeVisible();
|
||||
expect(await editor.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||||
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-mobile-500.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
|
||||
const requests = await install(page);
|
||||
await page.goto('/');
|
||||
@@ -1194,7 +1374,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
|
||||
await blockedFire.click();
|
||||
await picker.getByTestId('command-argument-form').locator('select').selectOption('2');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('화계');
|
||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
|
||||
|
||||
await drag(0, 2);
|
||||
await editor.locator('details.selected-menu > summary').click();
|
||||
@@ -1205,7 +1385,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
|
||||
await expect(editor.locator('.index-column > button.selected')).toHaveCount(15);
|
||||
await editor.locator('details.selected-menu > summary').click();
|
||||
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
|
||||
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계');
|
||||
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('【허창】에 화계실행');
|
||||
|
||||
await drag(0, 2);
|
||||
page.once('dialog', (dialog) => dialog.accept('화계 세트'));
|
||||
@@ -1284,7 +1464,9 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
await chiefArgumentForm.locator('input[type=number]').fill('300');
|
||||
await chiefArgumentForm.locator('select').selectOption('2');
|
||||
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상');
|
||||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
||||
'【관우】 쌀 300 포상'
|
||||
);
|
||||
expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
|
||||
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
||||
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
|
||||
|
||||
@@ -875,6 +875,18 @@ test('current-city wraps dense general names and only shrinks reserved turns', a
|
||||
expect(pageStyle.fontFamily).toContain('Pretendard');
|
||||
expect(pageStyle.fontSize).toBe('14px');
|
||||
|
||||
const controlFonts = await page
|
||||
.locator('.city-page')
|
||||
.evaluate(() =>
|
||||
['#citySelector', '.back-link'].map(
|
||||
(selector) => getComputedStyle(document.querySelector(selector)!).fontFamily
|
||||
)
|
||||
);
|
||||
expect(controlFonts).toHaveLength(2);
|
||||
for (const fontFamily of controlFonts) {
|
||||
expect(fontFamily).toContain('Pretendard');
|
||||
}
|
||||
|
||||
const names = page.locator('.general-names');
|
||||
await expect(names).toHaveCSS('white-space', 'normal');
|
||||
const nameLineCount = await names.locator('span').evaluateAll((elements) => {
|
||||
|
||||
@@ -118,7 +118,8 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string)
|
||||
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
const installFixture = async (page: Page, options: { applicationOpen?: boolean } = {}) => {
|
||||
let joined = false;
|
||||
await page.addInitScript((profile) => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
||||
window.localStorage.setItem('sammo-game-profile', profile);
|
||||
@@ -141,7 +142,7 @@ const installFixture = async (page: Page) => {
|
||||
if (operation === 'tournament.getSnapshot') {
|
||||
return response({
|
||||
state: {
|
||||
stage: 0,
|
||||
stage: options.applicationOpen ? 1 : 0,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: false,
|
||||
@@ -151,11 +152,32 @@ const installFixture = async (page: Page) => {
|
||||
nextAt: '2026-08-02T00:00:00.000Z',
|
||||
winnerId: 1,
|
||||
},
|
||||
participants,
|
||||
participants:
|
||||
options.applicationOpen && !joined
|
||||
? []
|
||||
: options.applicationOpen
|
||||
? [
|
||||
{
|
||||
...participants[0],
|
||||
groupId: 0,
|
||||
groupNo: 0,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
seedRank: 0,
|
||||
finalRank: 0,
|
||||
},
|
||||
]
|
||||
: participants,
|
||||
matches,
|
||||
betCount: 16,
|
||||
});
|
||||
}
|
||||
if (operation === 'tournament.join') {
|
||||
joined = true;
|
||||
return response({ ok: true, count: 1 });
|
||||
}
|
||||
if (operation === 'tournament.getBettingSummary') {
|
||||
return response({
|
||||
totals: Object.fromEntries(
|
||||
@@ -245,9 +267,67 @@ test('desktop bracket connects every real general slot to the next round', async
|
||||
expect(geometry.horizontalIdentities).toBe(true);
|
||||
expect(Math.abs(geometry.firstParentY - geometry.firstPairAverageY)).toBeLessThan(1);
|
||||
|
||||
const controls = await page.locator('#tournament-container').evaluate((container) => {
|
||||
const bounds = (selector: string) => container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
const refresh = bounds('.toolbar button:first-child');
|
||||
const join = bounds('.join-button');
|
||||
const close = bounds('.close-button');
|
||||
return {
|
||||
refresh: { width: refresh.width, height: refresh.height },
|
||||
join: { width: join.width, height: join.height },
|
||||
close: { width: close.width, height: close.height },
|
||||
};
|
||||
});
|
||||
expect(controls.refresh).toEqual({ width: 72, height: 44 });
|
||||
expect(controls.join).toEqual({ width: 72, height: 44 });
|
||||
expect(controls.close).toEqual({ width: 88, height: 44 });
|
||||
|
||||
const firstSlot = page.locator('.desktop-bracket-name').first();
|
||||
const oddsContainment = await firstSlot.evaluate((slot) => {
|
||||
const card = slot.getBoundingClientRect();
|
||||
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
|
||||
return {
|
||||
cardTop: card.top,
|
||||
cardBottom: card.bottom,
|
||||
oddsTop: odds.top,
|
||||
oddsBottom: odds.bottom,
|
||||
cardHeight: card.height,
|
||||
};
|
||||
});
|
||||
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
||||
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
|
||||
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
|
||||
|
||||
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||
});
|
||||
|
||||
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await installFixture(page, { applicationOpen: true });
|
||||
await page.goto('tournament');
|
||||
|
||||
const refresh = page.getByRole('button', { name: '갱신' });
|
||||
const join = page.getByRole('button', { name: '참가' });
|
||||
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
||||
await expect(join).toBeEnabled();
|
||||
await join.click();
|
||||
|
||||
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
|
||||
await expect(join).toBeDisabled();
|
||||
await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible();
|
||||
|
||||
for (const control of [refresh, join, close]) {
|
||||
const box = await control.boundingBox();
|
||||
expect(box?.height).toBe(44);
|
||||
expect(box?.width).toBeGreaterThanOrEqual(72);
|
||||
}
|
||||
await refresh.focus();
|
||||
await expect(refresh).toBeFocused();
|
||||
await refresh.hover();
|
||||
await expect(refresh).toHaveCSS('filter', 'brightness(1.25)');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
});
|
||||
|
||||
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -325,6 +405,14 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
|
||||
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1);
|
||||
expect(identity.nameTop).toBeLessThan(identity.iconBottom);
|
||||
expect(identity.nameBottom).toBeGreaterThan(identity.iconTop);
|
||||
const firstMobileSlot = bracket.locator('.mobile-bracket-name').first();
|
||||
const mobileOddsContainment = await firstMobileSlot.evaluate((slot) => {
|
||||
const card = slot.getBoundingClientRect();
|
||||
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
|
||||
return { cardBottom: card.bottom, oddsBottom: odds.bottom, cardHeight: card.height };
|
||||
});
|
||||
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
||||
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
|
||||
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
|
||||
await page.getByRole('tab', { name: '二조' }).first().click();
|
||||
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
||||
@import 'tailwindcss';
|
||||
@import './styles/tokens.css';
|
||||
@import './styles/legacy-controls.css';
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
CommandTable,
|
||||
ReservedCommandRow,
|
||||
} from './types';
|
||||
import { formatReservedCommandBrief } from './reservedCommandBrief';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -37,6 +38,7 @@ const props = withDefaults(
|
||||
currentTime?: string;
|
||||
mapData?: CommandMapData | null;
|
||||
mapLayout?: CommandMapLayout | null;
|
||||
autonomousUntil?: string | null;
|
||||
}>(),
|
||||
{
|
||||
maxPushTurn: 6,
|
||||
@@ -47,6 +49,7 @@ const props = withDefaults(
|
||||
currentTime: '--:--:--',
|
||||
mapData: null,
|
||||
mapLayout: null,
|
||||
autonomousUntil: null,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -70,6 +73,7 @@ const commandArgsValid = ref(false);
|
||||
const expanded = ref(false);
|
||||
const menuRevision = ref(0);
|
||||
const pendingReservation = ref<CommandPatternEntry | null>(null);
|
||||
const editorElement = ref<HTMLElement | null>(null);
|
||||
const pickerElement = ref<HTMLElement | null>(null);
|
||||
const collapsedRowCount = 15;
|
||||
|
||||
@@ -133,12 +137,16 @@ const labelMap = computed(() => {
|
||||
const displayRows = computed(() =>
|
||||
props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount)
|
||||
);
|
||||
const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`);
|
||||
const quickPickerTop = ref('38px');
|
||||
const isRecruitmentCommand = computed(
|
||||
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
|
||||
);
|
||||
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
|
||||
const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action;
|
||||
const rowLabel = (row: ReservedCommandRow): string =>
|
||||
formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) ||
|
||||
row.label ||
|
||||
labelMap.value.get(row.action) ||
|
||||
row.action;
|
||||
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
|
||||
const pattern = () => extractPattern(props.rows, selectedIndices());
|
||||
const touchMenus = () => (menuRevision.value += 1);
|
||||
@@ -163,6 +171,19 @@ const finishDrag = (next: Set<number>) => {
|
||||
};
|
||||
|
||||
const openPicker = (turnIndex?: number) => {
|
||||
if (!props.compact && editorElement.value) {
|
||||
const editorRect = editorElement.value.getBoundingClientRect();
|
||||
const anchor =
|
||||
turnIndex === undefined
|
||||
? editorElement.value.querySelector<HTMLElement>('.control-pad')
|
||||
: editorElement.value.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`);
|
||||
if (anchor) {
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
quickPickerTop.value = `${
|
||||
turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30
|
||||
}px`;
|
||||
}
|
||||
}
|
||||
quickTarget.value = turnIndex ?? null;
|
||||
pickerOpen.value = true;
|
||||
selectedCommand.value = null;
|
||||
@@ -281,7 +302,7 @@ const rearrange = (direction: 'pull' | 'push') => {
|
||||
const textCopy = async () => {
|
||||
const lines = selectedIndices().map((index) => {
|
||||
const row = props.rows[index];
|
||||
return `${index + 1}턴 ${row?.label ?? labelMap.value.get(row?.action ?? '') ?? row?.action ?? ''}`;
|
||||
return `${index + 1}턴 ${row ? rowLabel(row) : ''}`;
|
||||
});
|
||||
await navigator.clipboard.writeText(lines.join('\n'));
|
||||
releaseSelection();
|
||||
@@ -310,6 +331,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
|
||||
<template>
|
||||
<article
|
||||
ref="editorElement"
|
||||
class="reserved-command-editor"
|
||||
:class="{
|
||||
compact: props.compact,
|
||||
@@ -326,6 +348,10 @@ const clickOutsideMenu = (event: Event) => {
|
||||
<span>{{ props.title }} :</span><strong>{{ props.name ?? '-' }}</strong>
|
||||
</header>
|
||||
|
||||
<div v-if="props.autonomousUntil" class="autorun-status" data-command-autorun-status role="status">
|
||||
자율 행동: {{ props.autonomousUntil }}
|
||||
</div>
|
||||
|
||||
<div class="editor-layout">
|
||||
<aside class="control-pad">
|
||||
<div v-if="props.mobile && props.compact" class="mobile-identity legacy-bg1">
|
||||
@@ -614,7 +640,11 @@ const clickOutsideMenu = (event: Event) => {
|
||||
<div
|
||||
v-for="row in displayRows"
|
||||
:key="row.index"
|
||||
:title="row.autonomous ? `${rowLabel(row)} · 자율 행동` : rowLabel(row)"
|
||||
:title="
|
||||
row.autonomous
|
||||
? `${rowLabel(row)} · 자율 행동${props.autonomousUntil ? ` (${props.autonomousUntil})` : ''}`
|
||||
: rowLabel(row)
|
||||
"
|
||||
:class="{ autonomous: row.autonomous }"
|
||||
>
|
||||
<span>{{ rowLabel(row) }}</span>
|
||||
@@ -655,7 +685,9 @@ const clickOutsideMenu = (event: Event) => {
|
||||
class="command-picker"
|
||||
:class="{ 'recruitment-picker': isRecruitmentCommand }"
|
||||
data-testid="command-picker"
|
||||
:style="isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
|
||||
:style="
|
||||
isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
|
||||
"
|
||||
:role="isRecruitmentCommand ? 'dialog' : undefined"
|
||||
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
|
||||
:aria-label="
|
||||
@@ -855,6 +887,15 @@ const clickOutsideMenu = (event: Event) => {
|
||||
display: grid;
|
||||
grid-template-columns: 75px 40px minmax(0, 1fr) 38px;
|
||||
}
|
||||
.autorun-status {
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid #2d6574;
|
||||
background: #102c35;
|
||||
color: #aaffff;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
.queue-grid.advanced {
|
||||
grid-template-columns: 34px 75px 40px minmax(0, 1fr);
|
||||
}
|
||||
@@ -1107,12 +1148,15 @@ const clickOutsideMenu = (event: Event) => {
|
||||
}
|
||||
|
||||
.mobile.compact .editor-layout {
|
||||
height: 360px;
|
||||
min-height: 370px;
|
||||
display: grid;
|
||||
grid-template-columns: 109px 391px;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
.mobile.compact .control-pad {
|
||||
order: initial;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / -1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1120,6 +1164,8 @@ const clickOutsideMenu = (event: Event) => {
|
||||
}
|
||||
.mobile.compact .queue-area {
|
||||
order: initial;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.mobile.compact .queue-grid {
|
||||
@@ -1150,9 +1196,8 @@ const clickOutsideMenu = (event: Event) => {
|
||||
overflow: visible;
|
||||
}
|
||||
.mobile.compact .advanced-actions {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 109px;
|
||||
position: static;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { CommandOption, CommandTable } from './types';
|
||||
|
||||
type CommandScope = 'general' | 'nation';
|
||||
type CommandArgs = Record<string, unknown>;
|
||||
|
||||
const CITY_TO_COMMANDS = new Set(['che_이동', 'che_강행', 'che_출병', 'che_천도']);
|
||||
const CITY_AT_COMMANDS = new Set([
|
||||
'che_첩보',
|
||||
'che_화계',
|
||||
'che_선동',
|
||||
'che_탈취',
|
||||
'che_파괴',
|
||||
'che_백성동원',
|
||||
'che_허보',
|
||||
]);
|
||||
const CITY_OBJECT_COMMANDS = new Set(['che_수몰', 'che_초토화']);
|
||||
const NATION_AT_COMMANDS = new Set(['che_선전포고', 'che_급습', 'che_이호경식']);
|
||||
const NATION_PROPOSAL_COMMANDS = new Set(['che_불가침파기제의', 'che_종전제의']);
|
||||
|
||||
const ITEM_TYPE_NAMES: Record<string, string> = {
|
||||
horse: '명마',
|
||||
weapon: '무기',
|
||||
book: '서적',
|
||||
item: '도구',
|
||||
};
|
||||
|
||||
const asArgs = (value: unknown): CommandArgs =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as CommandArgs) : {};
|
||||
|
||||
const firstValue = (args: CommandArgs, keys: readonly string[]): unknown => {
|
||||
for (const key of keys) {
|
||||
if (args[key] !== undefined) return args[key];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const optionLabel = (options: readonly CommandOption[], value: unknown): string | null => {
|
||||
const option = options.find((entry) => entry.value === value || String(entry.value) === String(value));
|
||||
if (!option) return null;
|
||||
// 도시·장수 option에는 선택 보조용 소속 정보가 괄호로 붙지만 Ref brief에는 이름만 들어간다.
|
||||
return option.label.replace(/\s+\([^)]*\)$/u, '');
|
||||
};
|
||||
|
||||
const commandNameMap = (table: CommandTable | null, scope: CommandScope): Map<string, string> => {
|
||||
const result = new Map<string, string>([['휴식', '휴식']]);
|
||||
for (const group of table?.[scope] ?? []) {
|
||||
for (const command of group.values) result.set(command.key, command.name);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const defaultCommandName = (action: string): string => action.replace(/^(?:che_|cr_|event_)/u, '');
|
||||
|
||||
const numberText = (value: unknown, grouped = false): string => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return String(value ?? '');
|
||||
return grouped ? value.toLocaleString('en-US') : String(value);
|
||||
};
|
||||
|
||||
const wrap = (value: string): string => `【${value}】`;
|
||||
const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`;
|
||||
const wrappedWithParticle = (value: string, particle: '을' | '으로'): string =>
|
||||
`${wrap(value)}${JosaUtil.pick(value, particle)}`;
|
||||
|
||||
export const formatReservedCommandBrief = (
|
||||
scope: CommandScope,
|
||||
action: string,
|
||||
rawArgs: unknown,
|
||||
table: CommandTable | null
|
||||
): string => {
|
||||
const args = asArgs(rawArgs);
|
||||
const input = table?.inputOptions;
|
||||
const commandNames = commandNameMap(table, scope);
|
||||
const commandName = commandNames.get(action) ?? defaultCommandName(action);
|
||||
const cityName = optionLabel(input?.cities ?? [], firstValue(args, ['destCityId', 'destCityID']));
|
||||
const nationName = optionLabel(input?.nations ?? [], firstValue(args, ['destNationId', 'destNationID']));
|
||||
const generalName = optionLabel(
|
||||
input?.generalTargets?.[action] ?? input?.generals ?? [],
|
||||
firstValue(args, ['destGeneralId', 'destGeneralID'])
|
||||
);
|
||||
|
||||
if (CITY_TO_COMMANDS.has(action) && cityName) {
|
||||
return `${wrappedWithParticle(cityName, '으로')} ${commandName}`;
|
||||
}
|
||||
if (action === 'cr_인구이동' && cityName) {
|
||||
return `${wrappedWithParticle(cityName, '으로')} ${numberText(args.amount, true)}명 ${commandName}`;
|
||||
}
|
||||
if (CITY_AT_COMMANDS.has(action) && cityName) {
|
||||
const suffix =
|
||||
scope === 'nation' ? commandName : action === 'che_첩보' ? `${commandName} 실행` : `${commandName}실행`;
|
||||
return `${wrap(cityName)}에 ${suffix}`;
|
||||
}
|
||||
if (CITY_OBJECT_COMMANDS.has(action) && cityName) {
|
||||
return `${wrappedWithParticle(cityName, '을')} ${commandName}`;
|
||||
}
|
||||
if (action === 'che_임관' && nationName) {
|
||||
return `${wrappedWithParticle(nationName, '으로')} ${commandName}`;
|
||||
}
|
||||
if (NATION_AT_COMMANDS.has(action) && nationName) {
|
||||
return `${wrap(nationName)}에 ${commandName}`;
|
||||
}
|
||||
if (NATION_PROPOSAL_COMMANDS.has(action) && nationName) {
|
||||
return `${wrap(nationName)}에게 ${commandName}`;
|
||||
}
|
||||
if (action === 'che_불가침제의' && nationName) {
|
||||
return `${wrap(nationName)}에게 ${numberText(args.year)}년 ${numberText(args.month)}월까지 ${commandName}`;
|
||||
}
|
||||
if (action === 'che_등용' && generalName) {
|
||||
return `${wrappedWithParticle(generalName, '을')} ${commandName}`;
|
||||
}
|
||||
if (action === 'che_장수대상임관' && generalName) {
|
||||
return `${wrappedWithParticle(generalName, '을')} 따라 임관`;
|
||||
}
|
||||
if (action === 'che_선양' && generalName) {
|
||||
return `${wrap(generalName)}에게 ${commandName}`;
|
||||
}
|
||||
if (action === 'che_랜덤임관') {
|
||||
return '무작위 국가로 임관';
|
||||
}
|
||||
if ((action === 'che_건국' || action === 'cr_건국') && typeof args.nationName === 'string') {
|
||||
return `${wrappedWithParticle(args.nationName, '을')} 건국`;
|
||||
}
|
||||
if (action === 'che_무작위건국' && typeof args.nationName === 'string') {
|
||||
return `${wrappedWithParticle(args.nationName, '을')} 무작위 도시에 건국`;
|
||||
}
|
||||
if (action === 'che_군량매매') {
|
||||
return `군량 ${numberText(args.amount)}을 ${args.buyRice ? '구입' : '판매'}`;
|
||||
}
|
||||
if (action === 'che_헌납') {
|
||||
return `${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}을 ${commandName}`;
|
||||
}
|
||||
if (action === 'che_증여' && generalName) {
|
||||
return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}을 ${commandName}`;
|
||||
}
|
||||
if (action === 'che_징병' || action === 'che_모병') {
|
||||
const crewType = optionLabel(input?.crewTypes ?? [], args.crewType);
|
||||
if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}명 ${commandName}`;
|
||||
}
|
||||
if (action === 'che_숙련전환') {
|
||||
const srcArmType = optionLabel(input?.armTypes ?? [], args.srcArmType);
|
||||
const destArmType = optionLabel(input?.armTypes ?? [], args.destArmType);
|
||||
if (srcArmType && destArmType) return `${wrap(srcArmType)}숙련을 ${wrap(destArmType)}숙련으로 전환`;
|
||||
}
|
||||
if (action === 'che_장비매매') {
|
||||
const itemType = typeof args.itemType === 'string' ? args.itemType : '';
|
||||
if (args.itemCode === 'None') {
|
||||
const itemTypeName = ITEM_TYPE_NAMES[itemType];
|
||||
if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`;
|
||||
}
|
||||
const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode);
|
||||
if (itemName) {
|
||||
const itemRawName = itemName.replace(/\([^)]*\)$/u, '');
|
||||
return `${wrap(itemName)}${JosaUtil.pick(itemRawName, '을')} 구입`;
|
||||
}
|
||||
}
|
||||
if (action === 'che_발령' && generalName && cityName) {
|
||||
return `${wrap(generalName)}${wrappedWithParticle(cityName, '으로')} ${commandName}`;
|
||||
}
|
||||
if (action === 'che_부대탈퇴지시' && generalName) {
|
||||
return `${wrap(generalName)}${commandName}`;
|
||||
}
|
||||
if ((action === 'che_포상' || action === 'che_몰수') && generalName) {
|
||||
return `${wrap(generalName)} ${args.isGold ? '금' : '쌀'} ${numberText(args.amount, true)} ${commandName}`;
|
||||
}
|
||||
if (action === 'che_물자원조' && nationName && Array.isArray(args.amountList)) {
|
||||
return `${wrap(nationName)}에게 국고 ${numberText(args.amountList[0], true)} 병량 ${numberText(
|
||||
args.amountList[1],
|
||||
true
|
||||
)} ${commandName}`;
|
||||
}
|
||||
if (action === 'che_증축' || action === 'che_감축') {
|
||||
return `수도를 ${commandName}`;
|
||||
}
|
||||
if (action === 'che_국기변경') {
|
||||
return '【국기】를 변경';
|
||||
}
|
||||
if (action === 'che_국호변경' && typeof args.nationName === 'string') {
|
||||
return `국호를 ${wrappedWithParticle(args.nationName, '으로')} 변경`;
|
||||
}
|
||||
if (action === 'che_피장파장' && nationName) {
|
||||
const targetAction = typeof args.commandType === 'string' ? args.commandType : '';
|
||||
const targetName = commandNames.get(targetAction) ?? defaultCommandName(targetAction);
|
||||
return `${wrap(nationName)}에 ${wrap(targetName)} ${commandName}`;
|
||||
}
|
||||
|
||||
return commandName;
|
||||
};
|
||||
@@ -95,6 +95,7 @@ export type CommandTable = {
|
||||
cities: CommandOption[];
|
||||
nations: CommandOption[];
|
||||
generals: CommandOption[];
|
||||
generalTargets?: Record<string, CommandOption[]>;
|
||||
crewTypes: CommandOption[];
|
||||
armTypes: CommandOption[];
|
||||
nationTypes: CommandOption[];
|
||||
|
||||
@@ -33,6 +33,9 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !
|
||||
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||
if (field.options) return field.options;
|
||||
if (!field.optionSource) return [];
|
||||
if (field.optionSource === 'generals') {
|
||||
return props.options.generalTargets?.[props.commandKey] ?? props.options.generals;
|
||||
}
|
||||
if (field.optionSource === 'items') {
|
||||
return props.options.items[String(values.itemType ?? '')] ?? [];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { addMinutes } from 'date-fns';
|
||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import type {
|
||||
CommandMapData,
|
||||
CommandMapLayout,
|
||||
@@ -65,6 +65,26 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const autonomousUntil = computed(() => {
|
||||
if (props.autorunLimit == null) return null;
|
||||
const baseYear = props.currentYear ?? 0;
|
||||
const baseMonth = props.currentMonth ?? 1;
|
||||
const currentAbsoluteMonth = baseYear * 12 + baseMonth - 1;
|
||||
const lastAutonomousMonth = props.autorunLimit - 1;
|
||||
if (lastAutonomousMonth < currentAbsoluteMonth) return null;
|
||||
|
||||
const untilYear = Math.floor(lastAutonomousMonth / 12);
|
||||
const untilMonth = (lastAutonomousMonth % 12) + 1;
|
||||
const base = props.general?.turnTime ? new Date(props.general.turnTime) : null;
|
||||
const term = props.turnTermMinutes ?? 0;
|
||||
const expiresAt =
|
||||
base && Number.isFinite(base.getTime())
|
||||
? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term)
|
||||
: null;
|
||||
const currentTimeLabel = expiresAt ? formatLocalDateTime(expiresAt) : '현재시각 확인 불가';
|
||||
return `${untilYear}年 ${untilMonth}月 · ${currentTimeLabel}까지`;
|
||||
});
|
||||
|
||||
const currentServerTime = ref('--:--:--');
|
||||
let sampledServerTimeMs: number | null = null;
|
||||
let sampledClientTimeMs = 0;
|
||||
@@ -112,6 +132,7 @@ onUnmounted(() => {
|
||||
:current-time="currentServerTime"
|
||||
:map-data="props.mapData"
|
||||
:map-layout="props.mapLayout"
|
||||
:autonomous-until="autonomousUntil"
|
||||
@reserve-bulk="emit('set-general-turns', $event)"
|
||||
@shift="emit('shift-general-turns', $event)"
|
||||
@repeat="emit('repeat-general-turns', $event)"
|
||||
|
||||
@@ -41,7 +41,7 @@ const formattedLogs = computed(() =>
|
||||
.recent-log-list {
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ const roundColumns = computed(() => [
|
||||
]);
|
||||
const desktopX = [110, 355, 600, 845, 1090];
|
||||
const cardWidth = 190;
|
||||
const desktopSlotHeight = 88;
|
||||
const desktopCanvasHeight = desktopSlotHeight * 16;
|
||||
const slotY = (columnIndex: number, slotIndex: number) => {
|
||||
const slotHeight = 72 * 2 ** columnIndex;
|
||||
const slotHeight = desktopSlotHeight * 2 ** columnIndex;
|
||||
return slotHeight / 2 + slotIndex * slotHeight;
|
||||
};
|
||||
const connections = computed(() =>
|
||||
@@ -77,8 +79,8 @@ const mobilePairs = computed(() => {
|
||||
<div class="desktop-round-labels" aria-hidden="true">
|
||||
<strong v-for="label in roundLabels" :key="label">{{ label }}</strong>
|
||||
</div>
|
||||
<div class="desktop-bracket-canvas">
|
||||
<svg viewBox="0 0 1200 1152" aria-hidden="true">
|
||||
<div class="desktop-bracket-canvas" :style="{ height: `${desktopCanvasHeight}px` }">
|
||||
<svg :viewBox="`0 0 1200 ${desktopCanvasHeight}`" aria-hidden="true">
|
||||
<g v-for="connection in connections" :key="connection.id">
|
||||
<path
|
||||
class="bracket-connector"
|
||||
@@ -177,7 +179,6 @@ const mobilePairs = computed(() => {
|
||||
.desktop-bracket-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 1152px;
|
||||
}
|
||||
.desktop-bracket-canvas svg {
|
||||
position: absolute;
|
||||
@@ -200,7 +201,7 @@ const mobilePairs = computed(() => {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
width: clamp(140px, 16vw, 190px);
|
||||
min-height: 68px;
|
||||
min-height: 82px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
transform: translate(-50%, -50%);
|
||||
@@ -265,7 +266,7 @@ const mobilePairs = computed(() => {
|
||||
.mobile-bracket-name {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 68px;
|
||||
min-height: 82px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #555;
|
||||
background: rgb(58 33 24 / 94%);
|
||||
|
||||
@@ -15,3 +15,12 @@ export const formatLocalTimeSeconds = (value: string | Date): string => {
|
||||
.map((part) => String(part).padStart(2, '0'))
|
||||
.join(':');
|
||||
};
|
||||
|
||||
export const formatLocalDateTime = (value: string | Date): string => {
|
||||
const parsed = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime())) return '-';
|
||||
const date = [parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()]
|
||||
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, '0'))
|
||||
.join('-');
|
||||
return `${date} ${formatLocalTimeSeconds(parsed)}`;
|
||||
};
|
||||
|
||||
@@ -304,16 +304,16 @@ const placeBet = async (targetId: number) => {
|
||||
background: #142b42 var(--sammo-texture-blue);
|
||||
}
|
||||
.title {
|
||||
height: 55.6875px;
|
||||
min-height: 68px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 19.1875px;
|
||||
}
|
||||
.close-button {
|
||||
display: block;
|
||||
width: 62px;
|
||||
height: 35.5px;
|
||||
padding: 8px 12px;
|
||||
width: 88px;
|
||||
height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #375a7f;
|
||||
border-radius: 5.25px;
|
||||
background: #375a7f;
|
||||
@@ -323,10 +323,16 @@ const placeBet = async (targetId: number) => {
|
||||
text-decoration: none;
|
||||
}
|
||||
.toolbar {
|
||||
min-height: 36.5px;
|
||||
min-height: 46px;
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
}
|
||||
.toolbar button {
|
||||
min-width: 72px;
|
||||
height: 44px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.error {
|
||||
min-height: 32px;
|
||||
padding: 5px;
|
||||
|
||||
@@ -252,9 +252,7 @@ onMounted(() => {
|
||||
padding: 8px;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
font:
|
||||
16px/normal 'Times New Roman',
|
||||
serif;
|
||||
font: 16px/normal var(--sammo-font-sans);
|
||||
}
|
||||
|
||||
.legacy-board-page {
|
||||
|
||||
@@ -352,22 +352,34 @@ const repeatTurns = async (amount: number) => {
|
||||
</div>
|
||||
<div class="chief-overview-frame">
|
||||
<div class="chief-overview">
|
||||
<template v-for="chief in chiefViews" :key="chief.officerLevel">
|
||||
<ChiefTurnCard
|
||||
v-if="chief.name"
|
||||
:officer-level-text="chief.officerLevelText"
|
||||
:name="chief.name"
|
||||
:npc-state="chief.npcState"
|
||||
:rows="chief.rows"
|
||||
:compact="true"
|
||||
:selected="chief.officerLevel === selectedChief?.officerLevel"
|
||||
:is-me="chief.officerLevel === data.me.officerLevel"
|
||||
:clickable="true"
|
||||
:turn-time-label="chief.rows[0]?.time"
|
||||
@select="selectedChiefLevel = chief.officerLevel"
|
||||
/>
|
||||
<div v-else class="empty-chief-slot" aria-hidden="true"></div>
|
||||
</template>
|
||||
<div
|
||||
v-for="(rowChiefs, rowIndex) in [chiefViews.slice(0, 4), chiefViews.slice(4, 8)]"
|
||||
:key="rowIndex"
|
||||
class="chief-overview-row"
|
||||
>
|
||||
<div class="overview-turn-index legacy-bg0" :aria-label="`${rowIndex + 1}행 턴 번호`">
|
||||
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
|
||||
</div>
|
||||
<template v-for="chief in rowChiefs" :key="chief.officerLevel">
|
||||
<ChiefTurnCard
|
||||
v-if="chief.name"
|
||||
:officer-level-text="chief.officerLevelText"
|
||||
:name="chief.name"
|
||||
:npc-state="chief.npcState"
|
||||
:rows="chief.rows"
|
||||
:compact="true"
|
||||
:selected="chief.officerLevel === selectedChief?.officerLevel"
|
||||
:is-me="chief.officerLevel === data.me.officerLevel"
|
||||
:clickable="true"
|
||||
:turn-time-label="chief.rows[0]?.time"
|
||||
@select="selectedChiefLevel = chief.officerLevel"
|
||||
/>
|
||||
<div v-else class="empty-chief-slot" aria-hidden="true"></div>
|
||||
</template>
|
||||
<div class="overview-turn-index legacy-bg0" :aria-label="`${rowIndex + 1}행 턴 번호`">
|
||||
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -750,20 +762,25 @@ const repeatTurns = async (amount: number) => {
|
||||
.chief-overview-frame {
|
||||
width: 500px;
|
||||
height: 310px;
|
||||
margin-top: -3px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chief-overview {
|
||||
width: 445px;
|
||||
width: 500px;
|
||||
height: 310px;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chief-overview-row {
|
||||
width: 500px;
|
||||
height: 155px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 111.25px);
|
||||
grid-auto-rows: 155px;
|
||||
grid-template-columns: 12px repeat(4, 119px) 12px;
|
||||
}
|
||||
.chief-overview :deep(.chief-card) {
|
||||
width: 111.25px;
|
||||
width: 119px;
|
||||
height: 155px;
|
||||
border: 0;
|
||||
border-left: 1px solid #fff;
|
||||
@@ -787,13 +804,34 @@ const repeatTurns = async (amount: number) => {
|
||||
box-sizing: border-box;
|
||||
height: 20px !important;
|
||||
min-height: 20px !important;
|
||||
grid-template-rows: none;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 10px 10px;
|
||||
line-height: 10px;
|
||||
}
|
||||
.chief-overview :deep(.compact-name),
|
||||
.chief-overview :deep(.compact-meta) {
|
||||
height: 10px;
|
||||
line-height: 10px;
|
||||
}
|
||||
.chief-overview :deep(.row-time),
|
||||
.chief-overview :deep(.row-action) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.overview-turn-index {
|
||||
display: grid;
|
||||
grid-template-rows: 20px repeat(12, 11.25px);
|
||||
width: 12px;
|
||||
height: 155px;
|
||||
color: #fff;
|
||||
font-size: 0.55rem;
|
||||
line-height: 11.25px;
|
||||
text-align: center;
|
||||
}
|
||||
.overview-turn-index span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.mobile-readonly {
|
||||
width: 404px;
|
||||
height: 420px;
|
||||
@@ -879,7 +917,7 @@ const repeatTurns = async (amount: number) => {
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chief-overview {
|
||||
grid-template-columns: repeat(4, 111.25px);
|
||||
width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,8 +929,8 @@ const repeatTurns = async (amount: number) => {
|
||||
.chief-grid-row > .empty-chief-slot {
|
||||
height: 384px;
|
||||
}
|
||||
.chief-overview > .empty-chief-slot {
|
||||
width: 111.25px;
|
||||
.chief-overview-row > .empty-chief-slot {
|
||||
width: 119px;
|
||||
height: 155px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -366,7 +366,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
||||
border: 1px solid #767676;
|
||||
background: #6b6b6b;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 13.3333px;
|
||||
}
|
||||
.selector {
|
||||
@@ -470,7 +470,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -305,7 +305,7 @@ onMounted(loadDetail);
|
||||
width: 1000px;
|
||||
margin: 8px auto 0;
|
||||
color: #fff;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
@@ -384,7 +384,7 @@ onMounted(loadDetail);
|
||||
padding: 1px 6px;
|
||||
background: buttonface;
|
||||
color: buttontext;
|
||||
font-family: Arial;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 13.3333px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
|
||||
@@ -231,7 +231,7 @@ onMounted(loadDynasty);
|
||||
padding: 1px 6px;
|
||||
background: buttonface;
|
||||
color: buttontext;
|
||||
font-family: Arial;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 13.3333px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
|
||||
@@ -778,12 +778,7 @@ onMounted(() => {
|
||||
padding: 0 7px;
|
||||
color: #fff;
|
||||
height: 1597px;
|
||||
font:
|
||||
14px/21px Pretendard,
|
||||
'Apple SD Gothic Neo',
|
||||
'Noto Sans KR',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
font: 14px/21px var(--sammo-font-sans);
|
||||
}
|
||||
|
||||
.inherit-page.legacy-bg0 {
|
||||
|
||||
@@ -451,7 +451,7 @@ onMounted(() => {
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -522,12 +522,7 @@ onMounted(() => void loadPersonnel());
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font:
|
||||
14px/1.3 Pretendard,
|
||||
'Apple SD Gothic Neo',
|
||||
'Noto Sans KR',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
font: 14px/1.3 var(--sammo-font-sans);
|
||||
}
|
||||
.legacy-table {
|
||||
width: 1000px;
|
||||
|
||||
@@ -174,12 +174,7 @@ onMounted(load);
|
||||
.secret-page {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
font:
|
||||
14px Pretendard,
|
||||
'Apple SD Gothic Neo',
|
||||
'Noto Sans KR',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
font: 14px var(--sammo-font-sans);
|
||||
color: #fff;
|
||||
}
|
||||
.layout {
|
||||
|
||||
@@ -437,12 +437,7 @@ onMounted(() => void loadStratFinan());
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
background: var(--sammo-texture-walnut);
|
||||
font:
|
||||
14px/1.3 Pretendard,
|
||||
'Apple SD Gothic Neo',
|
||||
'Noto Sans KR',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
font: 14px/1.3 var(--sammo-font-sans);
|
||||
}
|
||||
.tiptap-compat-controls {
|
||||
display: none;
|
||||
|
||||
@@ -165,7 +165,7 @@ onMounted(() => {
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -515,8 +515,6 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
||||
|
||||
.select-pool-page {
|
||||
width: 1000px;
|
||||
min-width: 1000px;
|
||||
|
||||
@@ -423,7 +423,7 @@ onMounted(() => {
|
||||
.pageVote {
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -385,16 +385,16 @@ const start = async () => {
|
||||
background: #142b42 var(--sammo-texture-blue);
|
||||
}
|
||||
.legacy-title {
|
||||
height: 55.6875px;
|
||||
min-height: 68px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 19.1875px;
|
||||
}
|
||||
.close-button {
|
||||
display: block;
|
||||
width: 62px;
|
||||
height: 35.5px;
|
||||
padding: 8px 12px;
|
||||
width: 88px;
|
||||
height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #375a7f;
|
||||
border-radius: 5.25px;
|
||||
background: #375a7f;
|
||||
@@ -404,9 +404,15 @@ const start = async () => {
|
||||
text-decoration: none;
|
||||
}
|
||||
.toolbar {
|
||||
min-height: 36.5px;
|
||||
min-height: 46px;
|
||||
padding: 1px;
|
||||
}
|
||||
.toolbar button {
|
||||
min-width: 72px;
|
||||
height: 44px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.operator-row,
|
||||
.state-row,
|
||||
.error-row,
|
||||
|
||||
@@ -361,7 +361,7 @@ onMounted(() => {
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const sourceRoot = path.resolve(import.meta.dirname, '../src');
|
||||
const fontFamilyDeclaration = /font-family\s*:\s*([^;]+);/g;
|
||||
const fontShorthandDeclaration = /(?:^|[\s{])font\s*:\s*([^;]+);/gm;
|
||||
|
||||
const listStyleSources = async (): Promise<string[]> => {
|
||||
const entries = await readdir(sourceRoot, { recursive: true, withFileTypes: true });
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && (entry.name.endsWith('.css') || entry.name.endsWith('.vue')))
|
||||
.map((entry) => path.join(entry.parentPath, entry.name))
|
||||
.sort();
|
||||
};
|
||||
|
||||
void describe('game content font contract', () => {
|
||||
void it('loads Pretendard once from the global stylesheet entry', async () => {
|
||||
const importPattern =
|
||||
/@import url\(['"]https:\/\/cdn\.jsdelivr\.net\/gh\/orioncactus\/pretendard\/dist\/web\/static\/pretendard\.css['"]\);/g;
|
||||
const imports: string[] = [];
|
||||
|
||||
for (const file of await listStyleSources()) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
if (importPattern.test(source)) {
|
||||
imports.push(path.relative(sourceRoot, file));
|
||||
}
|
||||
importPattern.lastIndex = 0;
|
||||
}
|
||||
|
||||
assert.deepEqual(imports, ['assets/main.css']);
|
||||
});
|
||||
|
||||
void it('uses the shared sans token for every explicit content font declaration', async () => {
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const file of await listStyleSources()) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
|
||||
for (const match of source.matchAll(fontFamilyDeclaration)) {
|
||||
const value = match[1]?.trim();
|
||||
if (value !== 'inherit' && value !== 'var(--sammo-font-sans)') {
|
||||
violations.push(`${path.relative(sourceRoot, file)}: font-family: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of source.matchAll(fontShorthandDeclaration)) {
|
||||
const value = match[1]?.trim();
|
||||
if (value !== 'inherit' && !value?.includes('var(--sammo-font-sans)')) {
|
||||
violations.push(`${path.relative(sourceRoot, file)}: font: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
formatLocalDateTime,
|
||||
formatLocalTimeSeconds,
|
||||
formatSeoulDateTime,
|
||||
formatSeoulHourMinute,
|
||||
@@ -29,3 +30,12 @@ void test('formats an ISO instant with the client local clock', () => {
|
||||
assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected);
|
||||
assert.equal(formatLocalTimeSeconds('invalid'), '-');
|
||||
});
|
||||
|
||||
void test('formats an autorun expiry instant with the client local date and seconds', () => {
|
||||
const instant = new Date('2026-08-13T00:07:06.713Z');
|
||||
const expectedDate = [instant.getFullYear(), instant.getMonth() + 1, instant.getDate()]
|
||||
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, '0'))
|
||||
.join('-');
|
||||
assert.equal(formatLocalDateTime(instant), `${expectedDate} ${formatLocalTimeSeconds(instant)}`);
|
||||
assert.equal(formatLocalDateTime('invalid'), '-');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { formatReservedCommandBrief } from '../src/components/command/reservedCommandBrief.ts';
|
||||
import type { CommandAvailability, CommandTable } from '../src/components/command/types.ts';
|
||||
|
||||
const command = (key: string, name: string): CommandAvailability => ({
|
||||
key,
|
||||
name,
|
||||
reqArg: false,
|
||||
status: 'available',
|
||||
possible: true,
|
||||
inputFields: [],
|
||||
});
|
||||
|
||||
const generalNames: Array<[string, string]> = [
|
||||
['휴식', '휴식'],
|
||||
['che_이동', '이동'],
|
||||
['che_강행', '강행'],
|
||||
['che_출병', '출병'],
|
||||
['che_첩보', '첩보'],
|
||||
['che_화계', '화계'],
|
||||
['che_선동', '선동'],
|
||||
['che_탈취', '탈취'],
|
||||
['che_파괴', '파괴'],
|
||||
['che_임관', '임관'],
|
||||
['che_등용', '등용'],
|
||||
['che_장수대상임관', '장수 대상 임관'],
|
||||
['che_선양', '선양'],
|
||||
['che_랜덤임관', '랜덤 임관'],
|
||||
['che_건국', '건국'],
|
||||
['che_무작위건국', '무작위 건국'],
|
||||
['che_군량매매', '군량 매매'],
|
||||
['che_헌납', '헌납'],
|
||||
['che_증여', '증여'],
|
||||
['che_징병', '징병'],
|
||||
['che_모병', '모병'],
|
||||
['che_숙련전환', '숙련 전환'],
|
||||
['che_장비매매', '장비 매매'],
|
||||
];
|
||||
|
||||
const nationNames: Array<[string, string]> = [
|
||||
['휴식', '휴식'],
|
||||
['che_발령', '발령'],
|
||||
['che_부대탈퇴지시', '부대 탈퇴 지시'],
|
||||
['che_포상', '포상'],
|
||||
['che_몰수', '몰수'],
|
||||
['che_물자원조', '물자 원조'],
|
||||
['che_불가침제의', '불가침 제의'],
|
||||
['che_불가침파기제의', '불가침 파기 제의'],
|
||||
['che_종전제의', '종전 제의'],
|
||||
['che_선전포고', '선전포고'],
|
||||
['che_급습', '급습'],
|
||||
['che_이호경식', '이호경식'],
|
||||
['che_천도', '천도'],
|
||||
['che_백성동원', '백성 동원'],
|
||||
['che_허보', '허보'],
|
||||
['che_수몰', '수몰'],
|
||||
['che_초토화', '초토화'],
|
||||
['che_증축', '증축'],
|
||||
['che_감축', '감축'],
|
||||
['che_국기변경', '국기 변경'],
|
||||
['che_국호변경', '국호 변경'],
|
||||
['che_피장파장', '피장파장'],
|
||||
['cr_인구이동', '인구이동'],
|
||||
];
|
||||
|
||||
const table: CommandTable = {
|
||||
general: [{ category: '전체', values: generalNames.map(([key, name]) => command(key, name)) }],
|
||||
nation: [{ category: '전체', values: nationNames.map(([key, name]) => command(key, name)) }],
|
||||
inputOptions: {
|
||||
cities: [
|
||||
{ value: 2, label: '단양 (오 · 회계)' },
|
||||
{ value: 3, label: '업 (위)' },
|
||||
],
|
||||
nations: [
|
||||
{ value: 2, label: '오' },
|
||||
{ value: 3, label: '위' },
|
||||
],
|
||||
generals: [
|
||||
{ value: 8, label: '손권 (오 · 단양)' },
|
||||
{ value: 9, label: '조조 (위 · 업)' },
|
||||
],
|
||||
generalTargets: {
|
||||
che_포상: [
|
||||
{ value: 8, label: '손권 (오 · 단양)' },
|
||||
{ value: 10, label: '여포NPC (오 · 단양)' },
|
||||
],
|
||||
},
|
||||
crewTypes: [{ value: 1100, label: '보병' }],
|
||||
armTypes: [
|
||||
{ value: 0, label: '보병' },
|
||||
{ value: 1, label: '궁병' },
|
||||
],
|
||||
nationTypes: [],
|
||||
colors: [],
|
||||
items: {
|
||||
horse: [
|
||||
{ value: 'None', label: '판매/해제' },
|
||||
{ value: 'che_명마_01_노기', label: '노기(+1)' },
|
||||
],
|
||||
},
|
||||
recruitment: null,
|
||||
},
|
||||
};
|
||||
|
||||
test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인자 요약으로 표시한다', () => {
|
||||
assert.equal(formatReservedCommandBrief('general', 'che_출병', { destCityId: 2 }, table), '【단양】으로 출병');
|
||||
assert.equal(formatReservedCommandBrief('general', 'che_화계', { destCityId: 3 }, table), '【업】에 화계실행');
|
||||
assert.equal(formatReservedCommandBrief('general', 'che_선동', { destCityId: 2 }, table), '【단양】에 선동실행');
|
||||
assert.equal(
|
||||
formatReservedCommandBrief('general', 'che_모병', { crewType: 1100, amount: 2400 }, table),
|
||||
'【보병】 2400명 모병'
|
||||
);
|
||||
});
|
||||
|
||||
test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () => {
|
||||
const cases: Array<[string, Record<string, unknown>, string]> = [
|
||||
['che_이동', { destCityId: 3 }, '【업】으로 이동'],
|
||||
['che_강행', { destCityId: 2 }, '【단양】으로 강행'],
|
||||
['che_첩보', { destCityId: 2 }, '【단양】에 첩보 실행'],
|
||||
['che_임관', { destNationId: 2 }, '【오】로 임관'],
|
||||
['che_등용', { destGeneralId: 9 }, '【조조】를 등용'],
|
||||
['che_장수대상임관', { destGeneralId: 8 }, '【손권】을 따라 임관'],
|
||||
['che_선양', { destGeneralId: 8 }, '【손권】에게 선양'],
|
||||
['che_랜덤임관', {}, '무작위 국가로 임관'],
|
||||
['che_건국', { nationName: '촉한' }, '【촉한】을 건국'],
|
||||
['che_무작위건국', { nationName: '오' }, '【오】를 무작위 도시에 건국'],
|
||||
['che_군량매매', { amount: 500, buyRice: true }, '군량 500을 구입'],
|
||||
['che_헌납', { amount: 300, isGold: false }, '쌀 300을 헌납'],
|
||||
['che_증여', { destGeneralId: 8, amount: 200, isGold: true }, '【손권】에게 금 200을 증여'],
|
||||
['che_징병', { crewType: 1100, amount: 3200 }, '【보병】 3200명 징병'],
|
||||
['che_숙련전환', { srcArmType: 0, destArmType: 1 }, '【보병】숙련을 【궁병】숙련으로 전환'],
|
||||
['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '명마를 판매'],
|
||||
['che_장비매매', { itemType: 'horse', itemCode: 'che_명마_01_노기' }, '【노기(+1)】를 구입'],
|
||||
];
|
||||
for (const [action, args, expected] of cases) {
|
||||
assert.equal(formatReservedCommandBrief('general', action, args, table), expected, action);
|
||||
}
|
||||
});
|
||||
|
||||
test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표시한다', () => {
|
||||
const cases: Array<[string, Record<string, unknown>, string]> = [
|
||||
['che_발령', { destGeneralId: 8, destCityId: 3 }, '【손권】【업】으로 발령'],
|
||||
['che_부대탈퇴지시', { destGeneralId: 8 }, '【손권】부대 탈퇴 지시'],
|
||||
['che_포상', { destGeneralId: 8, amount: 12000, isGold: true }, '【손권】 금 12,000 포상'],
|
||||
['che_몰수', { destGeneralId: 9, amount: 3400, isGold: false }, '【조조】 쌀 3,400 몰수'],
|
||||
[
|
||||
'che_물자원조',
|
||||
{ destNationId: 2, amountList: [12000, 34000] },
|
||||
'【오】에게 국고 12,000 병량 34,000 물자 원조',
|
||||
],
|
||||
['che_불가침제의', { destNationId: 2, year: 190, month: 8 }, '【오】에게 190년 8월까지 불가침 제의'],
|
||||
['che_불가침파기제의', { destNationId: 2 }, '【오】에게 불가침 파기 제의'],
|
||||
['che_종전제의', { destNationId: 3 }, '【위】에게 종전 제의'],
|
||||
['che_선전포고', { destNationId: 2 }, '【오】에 선전포고'],
|
||||
['che_급습', { destNationId: 3 }, '【위】에 급습'],
|
||||
['che_이호경식', { destNationId: 2 }, '【오】에 이호경식'],
|
||||
['che_천도', { destCityId: 2 }, '【단양】으로 천도'],
|
||||
['che_백성동원', { destCityId: 3 }, '【업】에 백성 동원'],
|
||||
['che_허보', { destCityId: 2 }, '【단양】에 허보'],
|
||||
['che_수몰', { destCityId: 2 }, '【단양】을 수몰'],
|
||||
['che_초토화', { destCityId: 3 }, '【업】을 초토화'],
|
||||
['che_증축', {}, '수도를 증축'],
|
||||
['che_감축', {}, '수도를 감축'],
|
||||
['che_국기변경', { colorType: 2 }, '【국기】를 변경'],
|
||||
['che_국호변경', { nationName: '진' }, '국호를 【진】으로 변경'],
|
||||
['che_피장파장', { destNationId: 2, commandType: 'che_선전포고' }, '【오】에 【선전포고】 피장파장'],
|
||||
['cr_인구이동', { destCityId: 2, amount: 12000 }, '【단양】으로 12,000명 인구이동'],
|
||||
];
|
||||
for (const [action, args, expected] of cases) {
|
||||
assert.equal(formatReservedCommandBrief('nation', action, args, table), expected, action);
|
||||
}
|
||||
assert.equal(
|
||||
formatReservedCommandBrief('nation', 'che_포상', { destGeneralId: 10, amount: 300, isGold: false }, table),
|
||||
'【여포NPC】 쌀 300 포상'
|
||||
);
|
||||
});
|
||||
|
||||
test('Ref가 getBrief를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => {
|
||||
assert.equal(formatReservedCommandBrief('general', '휴식', {}, table), '휴식');
|
||||
assert.equal(formatReservedCommandBrief('general', 'che_훈련', {}, table), '훈련');
|
||||
assert.equal(formatReservedCommandBrief('nation', 'che_필사즉생', {}, table), '필사즉생');
|
||||
});
|
||||
Reference in New Issue
Block a user