merge: 최신 main을 비용 기반 갱신 점수에 통합

This commit is contained in:
2026-08-17 11:10:43 +00:00
68 changed files with 2128 additions and 306 deletions
@@ -58,7 +58,7 @@ const simulatorOptions = {
{ id: 200, name: '궁병', armType: 2 },
],
},
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }],
nationTypes: [{ key: 'che_도적', name: '도적', info: '금 수입 증가, 쌀 수입 감소' }],
eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }],
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
@@ -356,6 +356,10 @@ test('operates independent/game presets, imports my general, and renders battle
await page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page);
const nationTypeSelects = page.locator('[data-parity-id="attacker-nation"] select').first();
await expect(nationTypeSelects).toHaveValue('che_도적');
await expect(nationTypeSelects.locator('option[value="che_중립"]')).toHaveCount(0);
const notice = page.getByLabel('시뮬레이터 데이터 안내');
const noticeRect = await notice.boundingBox();
expect(noticeRect?.width).toBeLessThan(100);
+252 -15
View File
@@ -43,6 +43,7 @@ const inputOptions = {
cities: [
{ value: 1, label: '업 (아국)' },
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
{ value: 3, label: '단양 (오)' },
],
nations: [
{ value: 1, label: '아국', color: '#008000' },
@@ -52,9 +53,21 @@ 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: '중립' }],
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
items: { horse: [{ value: 'None', label: '판매/해제' }] },
recruitment: {
@@ -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,12 +596,67 @@ 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') });
});
test('defaults founding to a Ref-selectable nation trait without exposing the neutral storage trait', async ({
page,
}) => {
const foundingCommandTable = {
general: [
{
category: '국가',
values: [
{
key: 'che_건국',
name: '건국',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 },
{
key: 'nationType',
label: '국가 성향',
kind: 'select',
required: true,
optionSource: 'nationTypes',
},
{
key: 'colorType',
label: '국기 색상',
kind: 'select',
required: true,
optionSource: 'colors',
},
],
},
],
},
],
nation: [],
inputOptions,
};
await install(page, false, foundingCommandTable);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '건국', exact: true }).click();
const nationType = picker.getByLabel('국가 성향');
await expect(nationType).toHaveValue('che_도적');
await expect(nationType.locator('option[value="che_중립"]')).toHaveCount(0);
await expect(nationType.locator('option')).toHaveText(['도적']);
await nationType.focus();
await expect(nationType).toBeFocused();
await picker.screenshot({ path: test.info().outputPath('founding-selectable-nation-trait-desktop-1200.png') });
});
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
const specialCommandTable = {
general: [
@@ -654,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":{}');
@@ -723,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 });
@@ -801,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();
@@ -811,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();
@@ -824,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);
@@ -906,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');
@@ -1112,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('/');
@@ -1139,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();
@@ -1150,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('화계 세트'));
@@ -1229,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) => ({
+12
View File
@@ -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) => {
+91
View File
@@ -791,6 +791,97 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
});
test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
recentRecords: {
global: [],
general: [
{
id: 18703,
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다.',
createdAt: '2026-01-01T03:54:00.000Z',
},
{
id: 18702,
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다.',
createdAt: '2026-01-01T03:55:00.000Z',
},
{
id: 18701,
text: '<C>●</>10월:이미 기록된 시각 <1>12:34</>',
createdAt: '2026-01-01T03:56:00.000Z',
},
],
history: [],
},
};
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('');
const inspect = async (selector: string) => {
const lines = page.locator(selector);
await expect(lines).toHaveCount(3);
await expect(lines.nth(0)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 수비합니다. 12:54');
await expect(lines.nth(1)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 공격합니다. 12:55');
await expect(lines.nth(2)).toHaveText('●10월:이미 기록된 시각 12:34');
return lines.evaluateAll((elements) =>
elements.map((element) => {
const spans = [...element.querySelectorAll<HTMLElement>('span')];
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
const name = spans.find((span) => span.textContent === 'ⓝ염행');
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
if (!time) throw new Error('개인 기록 시각 span을 찾지 못했습니다.');
const rect = element.getBoundingClientRect();
return {
text: element.textContent,
row: {
width: rect.width,
height: rect.height,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
fontSize: getComputedStyle(element).fontSize,
lineHeight: getComputedStyle(element).lineHeight,
},
time: {
fontSize: getComputedStyle(time).fontSize,
lineHeight: getComputedStyle(time).lineHeight,
},
nameFontSize: name ? getComputedStyle(name).fontSize : null,
actionFontSize: action ? getComputedStyle(action).fontSize : null,
timeSpanCount: spans.filter((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')).length,
};
})
);
};
const assertFontContract = (measurements: Awaited<ReturnType<typeof inspect>>) => {
expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']);
expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']);
expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]);
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']);
expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]);
expect(measurements[0]?.nameFontSize).toBe('14px');
expect(measurements[0]?.actionFontSize).toBe('14px');
expect(measurements[1]?.nameFontSize).toBe('14px');
expect(measurements[1]?.actionFontSize).toBe('14px');
expect(measurements.every((entry) => entry.row.scrollWidth <= entry.row.clientWidth)).toBe(true);
};
const desktop = await inspect('.record-zone [data-record-bucket="general"] .record-line');
assertFontContract(desktop);
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-desktop', desktop);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await inspect('.record-zone-mobile [data-record-bucket="general"] .record-line');
assertFontContract(mobile);
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-mobile', mobile);
});
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state);
@@ -827,6 +827,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true });
await expect(page.locator('.main-global-menu')).toHaveCount(3);
expect(await gridColumnCount(page, '.main-global-menu')).toBe(8);
@@ -2255,6 +2256,222 @@ test('nation menu presentation follows the server-derived permission matrix', as
);
});
test('all main Lumen button families share the rounded pressed geometry', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
nationColor: '#663399',
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const controls: Array<[string, Locator]> = [
[
'천통국 베팅',
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
],
[
'게임정보',
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
exact: true,
}),
],
['회 의 실', page.locator('.layout-desktop [data-navigation-id="meeting"]')],
['기 밀 실', page.locator('.layout-desktop [data-navigation-id="secret-board"]')],
[
'당기기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }),
],
[
'미루기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }),
],
[
'펼치기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }),
],
['실시간 동기화', page.locator('.desktop-action-controls').getByRole('button', { name: / :/u })],
['갱 신', page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' })],
['로비로', page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' })],
];
const measure = (control: Locator) =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderTop: style.borderTopWidth,
borderRight: style.borderRightWidth,
borderBottom: style.borderBottomWidth,
borderLeft: style.borderLeftWidth,
radius: style.borderRadius,
background: style.backgroundColor,
filter: style.filter,
};
});
const evidence: Record<string, Record<string, unknown>> = {};
for (const [index, [label, control]] of controls.entries()) {
await expect(control, `${label} control`).toBeVisible();
await expect(control).toHaveClass(/legacy-button/u);
await control.scrollIntoViewIfNeeded();
await page.mouse.move(1195, 895);
const base = await measure(control);
evidence[label] = { default: base };
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-default.png`) });
}
expect(base, `${label} default geometry`).toMatchObject({
marginTop: '0px',
borderTop: '0px',
borderRight: '1px',
borderBottom: '4px',
borderLeft: '1px',
radius: '5.25px',
filter: 'none',
});
await control.focus();
await expect(control, `${label} keyboard focus`).toBeFocused();
const focused = await measure(control);
evidence[label].focus = focused;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-focus.png`) });
}
expect(focused.borderBottom, `${label} focus edge`).toBe('4px');
expect(focused.marginTop, `${label} focus position`).toBe('0px');
await control.hover();
const hovered = await measure(control);
evidence[label].hover = hovered;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-hover.png`) });
}
expect(hovered.borderBottom, `${label} hover edge`).toBe('3px');
expect(hovered.marginTop, `${label} hover position`).toBe('1px');
expect(hovered.top, `${label} hover top`).toBeCloseTo(base.top + 1, 2);
expect(hovered.height, `${label} hover height`).toBeCloseTo(base.height - 1, 2);
expect(hovered.bottom, `${label} hover bottom`).toBeCloseTo(base.bottom, 2);
expect(hovered.background, `${label} hover face`).toBe(base.background);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} control has no bounding box`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const pressed = await measure(control);
evidence[label].pointerDown = pressed;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-pointer-down.png`) });
}
expect(pressed.borderBottom, `${label} pressed edge`).toBe('2px');
expect(pressed.marginTop, `${label} pressed position`).toBe('2px');
expect(pressed.top, `${label} pressed top`).toBeCloseTo(base.top + 2, 2);
expect(pressed.height, `${label} pressed height`).toBeCloseTo(base.height - 2, 2);
expect(pressed.bottom, `${label} pressed bottom`).toBeCloseTo(base.bottom, 2);
expect(pressed.background, `${label} pressed face`).toBe(base.background);
await page.mouse.move(1195, 895);
await page.mouse.up();
}
state.permission = 0;
await page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }).click();
const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]');
await expect(disabledSecret).toHaveAttribute('aria-disabled', 'true');
await disabledSecret.scrollIntoViewIfNeeded();
const disabledBase = await measure(disabledSecret);
await disabledSecret.hover({ force: true });
const disabledHover = await measure(disabledSecret);
evidence['기 밀 실 disabled'] = { default: disabledBase, hover: disabledHover };
expect(disabledHover.borderBottom).toBe('4px');
expect(disabledHover.marginTop).toBe('0px');
expect(disabledHover.top).toBeCloseTo(disabledBase.top, 2);
if (artifactRoot) {
await disabledSecret.screenshot({ path: resolve(artifactRoot, 'disabled-secret-hover.png') });
await writeFile(
resolve(artifactRoot, 'main-lumen-button-states.json'),
`${JSON.stringify(evidence, null, 2)}\n`
);
}
await persistArtifact(page, `${basePath.slice(1)}-main-lumen-button-families`);
});
test('mobile main Lumen button families keep the same state geometry without overflow', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
nationColor: '#663399',
};
await installFixture(page, state);
await page.setViewportSize({ width: 500, height: 900 });
await waitForMain(page);
const controls = [
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
exact: true,
}),
page.locator('.layout-mobile [data-navigation-id="meeting"]'),
page.locator('.layout-mobile [data-navigation-id="secret-board"]'),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }),
page.locator('.desktop-action-controls').getByRole('button', { name: / :/u }),
page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }),
page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' }),
];
for (const control of controls) {
await expect(control).toBeVisible();
await expect(control).toHaveClass(/legacy-button/u);
await expect(control).toHaveCSS('border-radius', '5.25px');
await expect(control).toHaveCSS('border-bottom-width', '4px');
}
for (const control of [controls[0], controls[2], controls[4], controls[7]]) {
if (!control) throw new Error('mobile Lumen control is missing');
await control.scrollIntoViewIfNeeded();
await control.focus();
await expect(control).toBeFocused();
await expect(control).toHaveCSS('border-bottom-width', '4px');
await control.hover();
await expect(control).toHaveCSS('border-bottom-width', '3px');
await expect(control).toHaveCSS('margin-top', '1px');
const box = await control.boundingBox();
if (!box) throw new Error('mobile Lumen control is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await expect(control).toHaveCSS('border-bottom-width', '2px');
await expect(control).toHaveCSS('margin-top', '2px');
await page.mouse.move(499, 899);
await page.mouse.up();
}
expect(
await page.evaluate(() => ({
document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
body: document.body.scrollWidth - document.body.clientWidth,
}))
).toEqual({ document: 0, body: 0 });
await persistArtifact(page, `${basePath.slice(1)}-mobile-main-lumen-button-families`);
});
test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
@@ -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
View File
@@ -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';
@@ -48,26 +48,6 @@
cursor: pointer;
}
/*
* Ref renders the dashboard reload control with the Lumen navigation family:
* the bottom edge shortens on hover and again while pressed.
*/
.game-shell__action--navigation {
border-color: var(--sammo-button-navigation-border);
border-width: 0 1px 4px;
background: var(--sammo-button-navigation-bg);
}
.game-shell__action--navigation:not(:disabled):hover {
margin-top: 1px;
border-bottom-width: 3px;
}
.game-shell__action--navigation:not(:disabled):active {
margin-top: 2px;
border-bottom-width: 2px;
}
.game-feedback--error {
color: var(--sammo-color-error);
font-size: 0.85rem;
@@ -56,7 +56,6 @@
--legacy-button-bg: var(--sammo-button-primary-bg);
--legacy-button-border: var(--sammo-button-primary-border);
--legacy-button-color: #fff;
min-height: 35.5px;
margin-top: 0;
border-color: var(--legacy-button-border);
border-style: solid;
@@ -65,6 +64,7 @@
padding: 5.25px 10.5px;
background: var(--legacy-button-bg);
color: var(--legacy-button-color);
filter: none;
line-height: 21px;
/* Ref's framework baseline for these controls. */
vertical-align: middle;
@@ -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>
@@ -635,9 +665,15 @@ const clickOutsideMenu = (event: Event) => {
</div>
<div v-if="!props.compact" class="bottom-actions">
<button type="button" @click="emit('shift', -1)">당기기</button>
<button type="button" @click="emit('shift', 1)">미루기</button>
<button type="button" @click="expanded = !expanded">{{ expanded ? '접기' : '펼치기' }}</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)">
당기기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)">
미루기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }}
</button>
</div>
</div>
</div>
@@ -649,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="
@@ -760,7 +798,6 @@ const clickOutsideMenu = (event: Event) => {
.control-pad > button,
.clock,
.legacy-menu > summary,
.bottom-actions button,
.select-command {
box-sizing: border-box;
min-height: 34px;
@@ -850,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);
}
@@ -1102,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;
@@ -1115,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 {
@@ -1145,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)"
@@ -26,10 +26,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry"
:enabled="isNavigationConfigured(entry)"
:active="isActive(entry)"
lumen-variant="navigation"
/>
<div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button
class="main-menu-button"
class="main-menu-button legacy-button legacy-button--navigation"
type="button"
:data-menu-id="entry.id"
:aria-expanded="openId === entry.id"
@@ -63,9 +64,10 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry.main"
:enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)"
lumen-variant="navigation"
/>
<button
class="main-menu-button main-menu-split__toggle"
class="main-menu-button main-menu-split__toggle legacy-button legacy-button--navigation"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
@@ -115,33 +117,6 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
min-width: 0;
}
.main-global-menu > :deep(.main-menu-link),
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link),
.main-menu-split > .main-menu-split__toggle {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
background-image: none;
}
.main-global-menu > :deep(.main-menu-link:hover),
.main-global-menu > :deep(.main-menu-link:focus-visible),
.main-global-menu > :deep(.main-menu-link:active),
.main-menu-popup > .main-menu-button:hover,
.main-menu-popup > .main-menu-button:focus-visible,
.main-menu-popup > .main-menu-button:active,
.main-menu-popup > .main-menu-button[aria-expanded='true'],
.main-menu-split > :deep(.main-menu-link:hover),
.main-menu-split > :deep(.main-menu-link:focus-visible),
.main-menu-split > :deep(.main-menu-link:active),
.main-menu-split > .main-menu-split__toggle:hover,
.main-menu-split > .main-menu-split__toggle:focus-visible,
.main-menu-split > .main-menu-split__toggle:active,
.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
}
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link) {
width: 100%;
@@ -157,7 +132,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
border-radius: 0 5.25px 5.25px 0;
}
.main-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
}
.menu-caret {
@@ -33,15 +33,17 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
:link="entry"
:enabled="isNationNavigationEnabled(entry, access)"
:active="isActive(entry)"
lumen-variant="lumen"
/>
<div v-else-if="entry.kind === 'split'" class="nation-menu-split">
<MainNavigationLink
:link="entry.main"
:enabled="isNationNavigationEnabled(entry.main, access)"
:active="isActive(entry.main)"
lumen-variant="lumen"
/>
<button
class="main-menu-button nation-menu-split__toggle"
class="main-menu-button nation-menu-split__toggle legacy-button legacy-button--lumen"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
@@ -81,13 +83,14 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
.main-nation-menu :deep(.main-menu-link),
.main-nation-menu .main-menu-button {
border-color: color-mix(in srgb, var(--nation-menu-color) 85%, #000);
background-color: var(--nation-menu-color);
--legacy-button-bg: var(--nation-menu-color);
--legacy-button-border: color-mix(in srgb, var(--nation-menu-color) 90%, #000);
--legacy-button-color: #fff;
background-image: none;
}
.main-nation-menu.dark-label :deep(.main-menu-link),
.main-nation-menu.dark-label .main-menu-button {
color: #000;
--legacy-button-color: #000;
}
.nation-menu-split {
@@ -106,7 +109,11 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
border-radius: 0 5.25px 5.25px 0;
}
.nation-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
}
.menu-caret {
@@ -8,11 +8,13 @@ const props = withDefaults(
enabled?: boolean;
compact?: boolean;
active?: boolean;
lumenVariant?: 'navigation' | 'lumen';
}>(),
{
enabled: true,
compact: false,
active: false,
lumenVariant: undefined,
}
);
@@ -22,13 +24,16 @@ const emit = defineEmits<{
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined));
const lumenClasses = computed(() =>
props.lumenVariant ? ['legacy-button', `legacy-button--${props.lumenVariant}`] : []
);
</script>
<template>
<RouterLink
v-if="enabled && link.to"
class="main-menu-link"
:class="{ highlight: active }"
:class="[lumenClasses, { highlight: active }]"
:to="link.to"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
@@ -40,7 +45,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<a
v-else-if="enabled && link.href"
class="main-menu-link"
:class="{ highlight: active }"
:class="[lumenClasses, { highlight: active }]"
:href="link.href"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
@@ -52,6 +57,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<span
v-else
class="main-menu-link disabled"
:class="lumenClasses"
role="link"
aria-disabled="true"
:title="link.unavailableReason"
@@ -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)}`;
};
@@ -256,6 +256,11 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
inheritBuff: { ...general.inheritBuff },
});
const resolveAvailableNationType = (candidate?: string | null): string => {
const available = options.value?.nationTypes ?? [];
return available.some((entry) => entry.key === candidate) ? (candidate as string) : (available[0]?.key ?? '');
};
const initializeDefaults = async () => {
loading.value = true;
error.value = null;
@@ -269,9 +274,9 @@ const initializeDefaults = async () => {
repeatCnt.value = 1;
seed.value = '';
const nationTypeDefault = context.nationTypes[0]?.key ?? 'che_중립';
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = me?.nation?.typeCode ?? nationTypeDefault;
const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
attackerNation.type = nationTypeDefault;
defenderNation.type = nationTypeDefault;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = me?.nation?.level ?? 0;
@@ -304,10 +309,10 @@ const applyGameEnvironment = () => {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
attackerNation.type = nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
@@ -326,7 +331,7 @@ const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
const nationTypeDefault = resolveAvailableNationType();
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
@@ -740,13 +745,13 @@ const importBattle = (data: BattleExport) => {
month.value = data.month;
repeatCnt.value = data.repeatCnt;
attackerNation.type = data.attackerNation.type;
attackerNation.type = resolveAvailableNationType(data.attackerNation.type);
attackerNation.level = data.attackerNation.level;
attackerNation.tech = Math.floor(data.attackerNation.tech / 1000);
attackerNation.isCapital = data.attackerNation.capital === 1;
attackerCity.level = data.attackerCity.level;
defenderNation.type = data.defenderNation.type;
defenderNation.type = resolveAvailableNationType(data.defenderNation.type);
defenderNation.level = data.defenderNation.level;
defenderNation.tech = Math.floor(data.defenderNation.tech / 1000);
defenderNation.isCapital = data.defenderNation.capital === 3;
+11 -5
View File
@@ -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;
+1 -3
View File
@@ -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 {
+63 -25
View File
@@ -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;
+1 -6
View File
@@ -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 {
+15 -33
View File
@@ -66,11 +66,12 @@ const nationAccess = computed(() => ({
}));
const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const recordTimeSuffixPattern = /\d{2}:\d{2}(?:<\/>)?\s*$/u;
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
if (!appendTime || recordTimeSuffixPattern.test(entry.text)) return formatLog(entry.text);
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!time) return formatLog(entry.text);
return formatLog(`${entry.text} ${time}`);
return formatLog(`${entry.text} <1>${time}</>`);
};
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -143,7 +144,7 @@ watch(
</h1>
<div class="game-shell__actions desktop-action-controls">
<button
class="game-shell__action toggle"
class="game-shell__action toggle legacy-button legacy-button--navigation"
:class="{ active: realtimeEnabled }"
type="button"
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
@@ -151,7 +152,7 @@ watch(
실시간 동기화: {{ realtimeLabel }}
</button>
<button
class="game-shell__action game-shell__action--navigation"
class="game-shell__action legacy-button legacy-button--navigation"
type="button"
:disabled="refreshing"
:aria-busy="refreshing"
@@ -159,7 +160,13 @@ watch(
>
</button>
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
<button
class="game-shell__action legacy-button legacy-button--navigation"
type="button"
@click="moveLobby"
>
로비로
</button>
</div>
</header>
@@ -475,10 +482,6 @@ button {
background-image: var(--sammo-texture-walnut);
}
.toggle.active {
background: rgba(201, 164, 90, 0.2);
}
.game-shell__action.highlight {
border-color: #f39c12;
background: #8a5b13;
@@ -750,29 +753,8 @@ button {
margin-top: 31px;
}
/*
* Ref renders these dashboard controls with the Lumen navigation family: no top
* border, 1px sides and a 4px bottom edge that shortens on hover and press
* while the control moves down.
*/
.desktop-action-controls .game-shell__action {
border-color: #004f28;
border-style: solid;
border-width: 0 1px 4px;
background: #006b36;
color: #fff;
}
.desktop-action-controls .game-shell__action:hover {
margin-top: 1px;
border-bottom-width: 3px;
background: #00582c;
}
.desktop-action-controls .game-shell__action:active {
margin-top: 2px;
border-bottom-width: 2px;
background: #005128;
font-weight: 400;
}
.placeholder {
@@ -793,8 +775,8 @@ button {
}
.desktop-action-controls .game-shell__action {
padding-right: 8px;
padding-left: 8px;
padding-right: 4px;
padding-left: 4px;
}
.main-page {
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
}
+11 -5
View File
@@ -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,
+1 -1
View File
@@ -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), '필사즉생');
});