fix: 메인 예약 명령 요약과 자율 행동 상태를 복원
예약 명령 mutation 응답에도 autorunLimit을 포함해 입력 직후 색상과 상태가 사라지지 않게 한다. Ref getBrief 계약에 맞춰 명령 인자를 요약하고 자율 행동 종료 연월과 현재 시각을 표시한다.
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' },
|
||||
@@ -182,6 +183,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 +334,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,
|
||||
@@ -483,7 +506,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 +543,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 +584,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') });
|
||||
@@ -654,7 +677,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":{}');
|
||||
@@ -801,7 +824,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();
|
||||
@@ -824,7 +849,9 @@ 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(
|
||||
'【관우】 쌀 300 포상'
|
||||
);
|
||||
|
||||
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
||||
expect(JSON.stringify(requests)).toContain('"isGold":false');
|
||||
@@ -906,7 +933,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 +1141,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 +1215,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 +1226,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 +1305,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) => ({
|
||||
|
||||
@@ -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>
|
||||
@@ -649,7 +679,7 @@ 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 || props.compact ? undefined : { top: quickPickerTop }"
|
||||
:role="isRecruitmentCommand ? 'dialog' : undefined"
|
||||
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
|
||||
:aria-label="
|
||||
@@ -850,6 +880,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);
|
||||
}
|
||||
@@ -1149,5 +1188,4 @@ const clickOutsideMenu = (event: Event) => {
|
||||
bottom: 0;
|
||||
left: 109px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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?.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;
|
||||
};
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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,174 @@
|
||||
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: '조조 (위 · 업)' },
|
||||
],
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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