merge: 최신 사령부 변경을 인게임 폰트 통일에 반영

This commit is contained in:
2026-08-17 10:57:21 +00:00
15 changed files with 487 additions and 52 deletions
+109 -5
View File
@@ -53,6 +53,18 @@ const inputOptions = {
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
],
generalTargets: {
che_포상: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
],
che_몰수: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
],
},
crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [{ value: 1, label: '보병' }],
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
@@ -389,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),
@@ -801,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 });
@@ -891,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();
@@ -905,13 +1009,13 @@ 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(
'【관우】 쌀 300 포상'
'【여포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);
@@ -685,7 +685,9 @@ const clickOutsideMenu = (event: Event) => {
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="isRecruitmentCommand || 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="
@@ -1146,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;
@@ -1159,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 {
@@ -1189,8 +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>
@@ -75,7 +75,10 @@ export const formatReservedCommandBrief = (
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']));
const generalName = optionLabel(
input?.generalTargets?.[action] ?? input?.generals ?? [],
firstValue(args, ['destGeneralId', 'destGeneralID'])
);
if (CITY_TO_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${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 ?? '')] ?? [];
}
+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>
@@ -81,6 +81,12 @@ const table: CommandTable = {
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 9, label: '조조 (위 · 업)' },
],
generalTargets: {
che_포상: [
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 10, label: '여포NPC (오 · 단양)' },
],
},
crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [
{ value: 0, label: '보병' },
@@ -165,6 +171,10 @@ test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표
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를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => {