fix(frontend): restore chief center command UI parity

This commit is contained in:
2026-08-02 05:10:35 +00:00
parent 43ec211ff8
commit 4e1058ae86
8 changed files with 1009 additions and 130 deletions
@@ -0,0 +1,22 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const frontendUrl = process.env.CHIEF_CENTER_LIVE_FRONTEND_URL ?? 'http://127.0.0.1:15160/hwe/';
export default defineConfig({
testDir: '.',
testMatch: ['chiefCenterLive.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 90_000,
expect: { timeout: 15_000 },
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/chief-center-live'),
use: {
baseURL: frontendUrl,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
});
@@ -0,0 +1,205 @@
import { randomUUID } from 'node:crypto';
import { expect, test, type Browser, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
const databaseUrl = process.env.CHIEF_CENTER_LIVE_DATABASE_URL;
const gameTokenSecret = process.env.CHIEF_CENTER_LIVE_GAME_SECRET;
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:1010';
const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret);
const gameSchema = profile.split(':', 1)[0] ?? '';
const resolveGameDatabaseUrl = (): string => {
const parsed = new URL(databaseUrl!);
const sourceSchema = parsed.searchParams.get('schema');
if (!gameSchema || (sourceSchema !== 'public' && sourceSchema !== gameSchema)) {
throw new Error(`Refusing unexpected chief-center schema: ${sourceSchema ?? '(missing)'}`);
}
parsed.searchParams.set('schema', gameSchema);
return parsed.toString();
};
const installSession = async (page: Page, userId: string, displayName: string): Promise<void> => {
const now = new Date();
const token = encryptGameSessionToken(
{
version: 1,
profile,
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
sessionId: `chief-center-live-${randomUUID()}`,
user: {
id: userId,
username: userId,
displayName,
roles: ['user'],
canUseGeneralPicture: false,
},
sanctions: {},
identity: {
kakaoVerified: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
gameTokenSecret!
);
await page.addInitScript(
({ gameToken, gameProfile }) => {
localStorage.setItem('sammo-game-token', gameToken);
localStorage.setItem('sammo-game-profile', gameProfile);
},
{ gameToken: token, gameProfile: profile }
);
};
const newPage = async (browser: Browser, userId: string, displayName: string): Promise<Page> => {
const context = await browser.newContext({
viewport: { width: 1365, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const page = await context.newPage();
await installSession(page, userId, displayName);
return page;
};
test('persists one chief command and exposes it to a normal nation user and another chief', async ({
browser,
}, testInfo) => {
test.skip(!hasLiveFixture, 'isolated chief-center PostgreSQL and token secret are required');
test.setTimeout(90_000);
const connector = createGamePostgresConnector({ url: resolveGameDatabaseUrl() });
await connector.connect();
const db = connector.prisma;
const editor = await db.general.findFirstOrThrow({ where: { name: 'GUI비교관리자' } });
const candidates = await db.general.findMany({
where: { nationId: editor.nationId, userId: null, id: { not: editor.id } },
orderBy: { id: 'asc' },
take: 2,
});
if (candidates.length !== 2) throw new Error('Two isolated visibility candidates are required.');
const [viewer, otherChief] = candidates;
const viewerUserId = `chief-center-viewer-${randomUUID()}`;
const otherChiefUserId = `chief-center-peer-${randomUUID()}`;
const originalTurns = await db.nationTurn.findMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
orderBy: { turnIdx: 'asc' },
});
const originalRevision = await db.nationTurnRevision.findUnique({
where: {
nationId_officerLevel: { nationId: editor.nationId, officerLevel: editor.officerLevel },
},
});
let selectedTargetId: number | undefined;
try {
await db.$transaction([
db.general.update({
where: { id: viewer.id },
data: {
userId: viewerUserId,
officerLevel: 1,
npcState: 0,
meta: { ...(viewer.meta as Record<string, unknown>), belong: 999 },
penalty: {},
},
}),
db.general.update({
where: { id: otherChief.id },
data: { userId: otherChiefUserId, officerLevel: 10, npcState: 0, penalty: {} },
}),
]);
const editorPage = await newPage(browser, editor.userId!, '사령부입력자');
await editorPage.goto('chief-center');
await expect(editorPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
await editorPage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = editorPage.getByTestId('chief-command-picker');
await expect(picker).toBeVisible();
await picker.getByRole('button', { name: '인사', exact: true }).click();
const reward = picker.getByRole('button', { name: /포상/ });
await expect(reward).toBeEnabled();
await reward.click();
const argumentForm = picker.getByTestId('command-argument-form');
await argumentForm.getByRole('button', { name: '쌀', exact: true }).click();
await argumentForm.locator('input[type=number]').fill('1');
const selectableGeneralIds = await argumentForm
.locator('select option')
.evaluateAll((options) =>
options
.map((option) => Number((option as HTMLOptionElement).value))
.filter((value) => Number.isInteger(value) && value > 0)
);
selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id);
if (!selectedTargetId) throw new Error('No reward target is available in the live command table.');
await argumentForm.locator('select').selectOption(String(selectedTargetId));
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(
editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()
).toHaveText('포상');
const persisted = await db.nationTurn.findUniqueOrThrow({
where: {
nationId_officerLevel_turnIdx: {
nationId: editor.nationId,
officerLevel: editor.officerLevel,
turnIdx: 0,
},
},
});
expect(persisted.actionCode).toBe('che_포상');
expect(persisted.arg).toEqual({ isGold: false, amount: 1, destGeneralId: selectedTargetId });
await editorPage.screenshot({ path: testInfo.outputPath('chief-editor-command-entered.png'), fullPage: true });
const viewerPage = await newPage(browser, viewerUserId, '일반국가원');
await viewerPage.goto('chief-center');
await expect(viewerPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
await expect(viewerPage.getByTestId('chief-command-editor')).toHaveCount(0);
await expect(viewerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible();
await viewerPage.screenshot({ path: testInfo.outputPath('chief-normal-user-visible.png'), fullPage: true });
const peerPage = await newPage(browser, otherChiefUserId, '다른수뇌');
await peerPage.goto('chief-center');
await expect(peerPage.getByTestId('chief-command-editor')).toBeVisible();
await expect(peerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible();
await peerPage.screenshot({ path: testInfo.outputPath('chief-peer-visible.png'), fullPage: true });
} finally {
await db.$transaction(async (transaction) => {
await transaction.nationTurn.deleteMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
});
if (originalTurns.length) await transaction.nationTurn.createMany({ data: originalTurns });
await transaction.nationTurnRevision.deleteMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
});
if (originalRevision) await transaction.nationTurnRevision.create({ data: originalRevision });
await transaction.general.update({
where: { id: viewer.id },
data: {
userId: viewer.userId,
officerLevel: viewer.officerLevel,
npcState: viewer.npcState,
meta: viewer.meta,
penalty: viewer.penalty,
},
});
await transaction.general.update({
where: { id: otherChief.id },
data: {
userId: otherChief.userId,
officerLevel: otherChief.officerLevel,
npcState: otherChief.npcState,
meta: otherChief.meta,
penalty: otherChief.penalty,
},
});
});
await connector.disconnect();
}
});
+17 -3
View File
@@ -211,6 +211,7 @@ const install = async (page: Page, rejectGeneral = false) => {
requests.push(body);
return response({
ok: true,
revision: 1,
turns: [{ index: 0, action: 'che_포상', args: { isGold: false, amount: 300, destGeneralId: 2 } }],
});
}
@@ -281,7 +282,7 @@ test('keeps the entered command visible and reports a server validation error',
});
test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => {
await install(page);
const requests = await install(page);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('/');
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
@@ -329,10 +330,23 @@ test('keeps the shared main and chief shell geometry and interaction states', as
await page.locator('.main-nation-menu').first().locator('[data-navigation-id="chief-center"]').click();
await expect(page).toHaveURL(/\/che\/chief-center$/);
await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await expect(page.getByTestId('chief-command-picker')).toBeVisible();
await page.getByTestId('chief-command-picker').getByRole('button', { name: /포상/ }).click();
const chiefArgumentForm = page.getByTestId('chief-command-picker').getByTestId('command-argument-form');
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
await chiefArgumentForm.locator('input[type=number]').fill('300');
await chiefArgumentForm.locator('select').selectOption('2');
await page.getByTestId('chief-command-picker').getByRole('button', { name: '입력', exact: true }).click();
await expect(page.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()).toHaveText(
'포상'
);
expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
width: element.getBoundingClientRect().width,
padding: getComputedStyle(element).padding,
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
}));
expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 });
@@ -340,7 +354,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as
const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({
width: element.getBoundingClientRect().width,
padding: getComputedStyle(element).padding,
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
}));
expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 });
});
@@ -16,6 +16,8 @@
"./npcPossessionLive.spec.ts",
"./npcPossession.live.playwright.config.mjs",
"./dieOnPrestartLive.spec.ts",
"./dieOnPrestart.live.playwright.config.mjs"
"./dieOnPrestart.live.playwright.config.mjs",
"./chiefCenterLive.spec.ts",
"./chiefCenter.live.playwright.config.mjs"
]
}
@@ -0,0 +1,445 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue';
import { getNpcColor } from '../../utils/npcColor';
type CommandOption = { value: string | number; label: string; color?: string };
type CommandInputField = {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
required: boolean;
min?: number;
max?: number;
step?: number;
constValue?: string | number;
options?: CommandOption[];
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
};
type CommandAvailability = {
key: string;
name: string;
reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
reason?: string;
inputFields: CommandInputField[];
};
type CommandTable = {
general: Array<{ category: string; values: CommandAvailability[] }>;
nation: Array<{ category: string; values: CommandAvailability[] }>;
inputOptions: {
cities: CommandOption[];
nations: CommandOption[];
generals: CommandOption[];
crewTypes: CommandOption[];
armTypes: CommandOption[];
nationTypes: CommandOption[];
colors: CommandOption[];
items: Record<string, CommandOption[]>;
};
};
type TurnRow = { index: number; time: string; action: string; isRest: boolean };
const props = defineProps<{
officerLevelText: string;
name: string | null;
npcState: number | null;
rows: TurnRow[];
commandTable: CommandTable | null;
loading: boolean;
mobile?: boolean;
}>();
const emit = defineEmits<{
(event: 'reserve', payload: { index: number; action: string; args: Record<string, unknown> }): void;
(event: 'shift', amount: number): void;
(event: 'repeat', amount: number): void;
}>();
const pickerTurnIndex = ref<number | null>(null);
const selectedCommand = ref<CommandAvailability | null>(null);
const commandArgs = ref<Record<string, unknown>>({});
const commandArgsValid = ref(false);
const editMode = ref(false);
const repeatAmount = ref(0);
const nationCategoryOrder = ['휴식', '인사', '외교', '특수', '전략', '국가'];
const nationOnlyTable = computed(() => {
if (!props.commandTable) return null;
const groupByCategory = new Map(props.commandTable.nation.map((group) => [group.category, group]));
const orderedGroups = nationCategoryOrder.map(
(category) => groupByCategory.get(category) ?? { category, values: [] }
);
const extraGroups = props.commandTable.nation.filter((group) => !nationCategoryOrder.includes(group.category));
return { ...props.commandTable, general: [], nation: [...orderedGroups, ...extraGroups] };
});
const nameColor = computed(() => (props.npcState !== null ? getNpcColor(props.npcState) : undefined));
const closePicker = () => {
pickerTurnIndex.value = null;
selectedCommand.value = null;
commandArgs.value = {};
commandArgsValid.value = false;
};
const openPicker = (turnIndex: number) => {
pickerTurnIndex.value = turnIndex;
selectedCommand.value = null;
commandArgs.value = {};
commandArgsValid.value = false;
};
const selectCommand = (commandKey: string) => {
const command =
props.commandTable?.nation.flatMap((group) => group.values).find((entry) => entry.key === commandKey) ?? null;
if (!command || pickerTurnIndex.value === null) return;
selectedCommand.value = command;
commandArgs.value = {};
commandArgsValid.value = !command.reqArg;
if (!command.reqArg) reserveSelected();
};
const reserveSelected = () => {
if (pickerTurnIndex.value === null || !selectedCommand.value || !commandArgsValid.value) return;
emit('reserve', {
index: pickerTurnIndex.value,
action: selectedCommand.value.key,
args: commandArgs.value,
});
closePicker();
};
</script>
<template>
<article class="chief-editor" :class="{ mobile: props.mobile }" data-testid="chief-command-editor">
<header v-if="!props.mobile" class="editor-header legacy-bg1">
<span>{{ props.officerLevelText }} :</span>
<strong :style="{ color: nameColor }">{{ props.name ?? '-' }}</strong>
</header>
<div class="editor-body">
<aside class="editor-controls">
<div v-if="props.mobile" class="mobile-identity legacy-bg1">
<strong :style="{ color: nameColor }">{{ props.name ?? '-' }}</strong>
<span>{{ props.officerLevelText }}</span>
</div>
<time>{{ props.rows[0]?.time ?? '--:--' }}</time>
<button type="button" @click="editMode = !editMode">{{ editMode ? '일반 모드' : '고급 모드' }}</button>
<select
v-model.number="repeatAmount"
class="repeat-control"
aria-label="반복 "
@change="repeatAmount > 0 && emit('repeat', repeatAmount)"
>
<option :value="0" disabled>반복</option>
<option v-for="amount in 6" :key="amount" :value="amount">{{ amount }}</option>
</select>
<button type="button" @click="emit('shift', -1)">당기기</button>
<button type="button" @click="emit('shift', 1)">미루기</button>
</aside>
<div class="editor-turns">
<div v-for="row in props.rows" :key="row.index" class="editor-turn-row">
<time>{{ row.time }}</time>
<strong>{{ row.action }}</strong>
<button
type="button"
class="edit-turn"
:aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)"
>
</button>
</div>
</div>
</div>
<div
v-if="pickerTurnIndex !== null"
:class="['command-picker', { 'has-command': selectedCommand }]"
data-testid="chief-command-picker"
>
<header>
<strong>{{ pickerTurnIndex + 1 }} 명령 입력</strong>
<button type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
</header>
<CommandSelectForm
v-if="!selectedCommand"
:command-table="nationOnlyTable"
:loading="props.loading"
scope="nation"
@select="selectCommand"
/>
<button v-if="!selectedCommand" type="button" class="picker-close" @click="closePicker">닫기</button>
<template v-else>
<div class="selected-command">{{ selectedCommand.name }}</div>
<CommandArgumentForm
v-if="selectedCommand.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
<div class="picker-actions">
<button type="button" @click="selectedCommand = null">명령 다시 선택</button>
<button type="button" :disabled="!commandArgsValid" @click="reserveSelected">입력</button>
</div>
</template>
</div>
</article>
</template>
<style scoped>
.chief-editor {
position: relative;
min-width: 0;
color: #fff;
background: #000;
}
.editor-header {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 16.8px;
font-weight: 400;
}
.editor-body {
display: flex;
flex-direction: column;
}
.editor-controls {
order: 2;
min-height: 85px;
padding: 2px 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
align-items: stretch;
}
.editor-controls > time {
display: grid;
place-items: center;
border-radius: 4px;
background: #345c85;
font-variant-numeric: tabular-nums;
}
.editor-controls button,
.repeat-control {
min-height: 36px;
border: 0;
border-radius: 4px;
background: #444;
color: #fff;
font: inherit;
font-weight: 700;
}
.editor-controls button {
cursor: pointer;
}
.repeat-control {
padding: 0 8px;
text-align: center;
}
.editor-turns {
order: 1;
display: grid;
grid-template-rows: repeat(12, 30px);
}
.editor-turn-row {
display: grid;
grid-template-columns: 55px minmax(0, 1fr) 36px;
align-items: center;
min-width: 0;
}
.editor-turn-row > time {
height: 30px;
display: grid;
place-items: center;
background: #000;
font-variant-numeric: tabular-nums;
}
.editor-turn-row > strong {
height: 30px;
display: grid;
place-items: center;
overflow: hidden;
background: #0d204d;
font-weight: 400;
white-space: nowrap;
text-overflow: ellipsis;
}
.editor-turn-row:nth-child(odd) > strong {
background: #12295d;
}
.edit-turn {
align-self: stretch;
border: 0;
background: #444;
color: #fff;
cursor: pointer;
}
.command-picker {
position: absolute;
z-index: 20;
top: 54px;
left: 0;
box-sizing: border-box;
width: 100%;
height: 344px;
overflow: auto;
border: 0;
padding: 0;
background: #303030;
}
.command-picker > header {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.command-picker.has-command {
padding: 8px;
}
.command-picker.has-command > header {
position: static;
width: auto;
height: auto;
display: flex;
justify-content: space-between;
align-items: center;
clip: auto;
margin-bottom: 8px;
}
.command-picker.has-command > header button {
width: 32px;
height: 28px;
}
.command-picker :deep(.command-form) {
gap: 4px;
padding-top: 0;
}
.command-picker :deep(.category-list) {
grid-template-columns: repeat(3, 1fr);
gap: 4px 2px;
}
.command-picker :deep(.category-btn) {
min-width: 0;
height: 35px;
border: 0;
border-radius: 4px;
padding: 4px;
background: #00a879;
color: #fff;
font-size: 16px;
font-weight: 700;
}
.command-picker :deep(.category-btn.active) {
background: #00bf91;
}
.command-picker :deep(.command-grid) {
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin-top: 4px;
}
.command-picker :deep(.command-item) {
min-height: 39px;
border: 1px solid #888;
border-radius: 5px;
padding: 5px;
display: grid;
place-items: center;
background: transparent;
color: #fff;
text-align: center;
font-size: 16px;
}
.command-picker :deep(.command-status) {
display: none;
}
.picker-close {
position: absolute;
right: 0;
bottom: 7px;
width: 65px;
height: 35px;
border: 0;
border-radius: 4px;
background: #444;
color: #fff;
font: inherit;
font-weight: 700;
}
.selected-command {
margin-bottom: 6px;
padding: 6px 8px;
background: #0d204d;
font-weight: 700;
}
.picker-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
.picker-actions button {
min-height: 34px;
}
.mobile-identity {
display: grid;
grid-column: 1 / -1;
min-height: 60px;
place-items: center;
}
.chief-editor.mobile .editor-header {
display: none;
}
.chief-editor.mobile {
margin-top: 10px;
}
.chief-editor.mobile .editor-body {
height: 360px;
display: grid;
grid-template-columns: 109px 391px;
}
.chief-editor.mobile .editor-controls {
order: initial;
min-height: 0;
padding: 0;
grid-template-columns: 1fr;
align-content: start;
}
.chief-editor.mobile .editor-controls > time {
min-height: 36px;
}
.chief-editor.mobile .editor-controls > button {
min-height: 36px;
margin-top: 5px;
}
.chief-editor.mobile .repeat-control {
min-height: 36px;
margin-top: 5px;
}
.chief-editor.mobile .editor-turns {
order: initial;
padding-top: 10px;
}
.chief-editor.mobile .editor-turn-row {
grid-template-columns: 74px minmax(0, 1fr) 53px;
}
.chief-editor.mobile .command-picker {
position: absolute;
top: 30px;
left: 130px;
width: 370px;
height: 327px;
}
</style>
@@ -18,6 +18,7 @@ const props = defineProps<{
compact?: boolean;
isMe?: boolean;
clickable?: boolean;
turnTimeLabel?: string;
}>();
const emit = defineEmits<{
@@ -40,13 +41,26 @@ const handleClick = () => {
@click="handleClick"
>
<header class="chief-header">
<div class="chief-title">
<span class="chief-level">{{ props.officerLevelText }}</span>
<span class="chief-name" :style="{ color: nameColor }">
{{ props.name ?? '-' }}
</span>
</div>
<span v-if="props.isMe" class="chief-me">ME</span>
<template v-if="props.compact">
<span
class="compact-name"
:style="{ color: nameColor, textDecoration: props.isMe ? 'underline' : undefined }"
>{{ props.name ?? '-' }}</span
>
<span class="compact-meta"
><span>{{ props.officerLevelText }}</span
><time>{{ props.turnTimeLabel ?? '--:--' }}</time></span
>
</template>
<template v-else>
<div class="chief-title">
<span class="chief-level">{{ props.officerLevelText }}</span>
<span class="chief-name" :style="{ color: nameColor }">
{{ props.name ?? '-' }}
</span>
</div>
<span v-if="props.isMe" class="chief-me">ME</span>
</template>
</header>
<div class="chief-rows">
<div v-for="row in props.rows" :key="row.index" class="chief-row" :class="{ rest: row.isRest }">
@@ -72,7 +86,9 @@ const handleClick = () => {
.chief-card.clickable {
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.chief-card.clickable:hover {
@@ -163,6 +179,28 @@ const handleClick = () => {
font-size: 0.65rem;
}
.compact-name,
.compact-meta {
display: grid;
place-items: center;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.compact-meta {
grid-template-columns: 1fr 1fr;
}
.chief-card.compact .chief-header {
height: 72px;
grid-template-rows: 36px 36px;
display: grid;
padding: 0;
}
.chief-card.compact .chief-row {
height: 46px;
line-height: 46px;
}
.chief-card.compact .chief-level,
.chief-card.compact .chief-name {
font-size: 0.6rem;
@@ -25,6 +25,7 @@ const props = defineProps<{
commandTable: CommandTable | null;
loading: boolean;
activeCategory?: string;
scope?: 'all' | 'general' | 'nation';
}>();
const emit = defineEmits<{
@@ -48,6 +49,10 @@ const categories = computed(() => {
category: group.category,
groupType: 'nation' as const,
}));
if (props.scope === 'general') return general;
if (props.scope === 'nation') {
return nation.map((entry) => ({ ...entry, label: entry.category === '국가' ? '기타' : entry.category }));
}
return [...general, ...nation];
});
@@ -58,7 +63,10 @@ const selectedGroup = computed(() => {
}
const [scope, ...categoryParts] = selectedCategory.value.split(':');
const category = categoryParts.join(':');
return props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? null;
return (
props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ??
null
);
});
watch(
@@ -109,9 +117,7 @@ const statusLabel = (command: CommandAvailability) => {
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.commandTable" class="empty">
명령 목록을 불러오지 못했습니다.
</div>
<div v-else-if="!props.commandTable" class="empty">명령 목록을 불러오지 못했습니다.</div>
<div v-else>
<div class="category-list">
<button
+261 -114
View File
@@ -4,6 +4,7 @@ import { useMediaQuery } from '@vueuse/core';
import { addMinutes, format } from 'date-fns';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
@@ -260,7 +261,7 @@ const selectedChiefRows = computed(() => {
return buildTurnRows(selectedChief.value);
});
const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => {
const updateMyTurns = (turns: Array<{ index: number; action: string; args?: unknown }>, revision: number) => {
if (!data.value) {
return;
}
@@ -269,29 +270,10 @@ const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => {
if (!entry) {
return;
}
entry.turns = turns;
entry.turns = turns.map((turn) => ({ ...turn, args: turn.args ?? {} }));
entry.revision = revision;
};
const clearTurn = async (turnIndex: number) => {
if (!data.value || !isEditingAllowed.value) {
return;
}
try {
const result = await trpc.turns.reserved.setNation.mutate({
generalId: data.value.me.id,
turnIndex,
action: '휴식',
args: {},
expectedRevision: selectedChief.value?.revision ?? 0,
});
updateMyTurns(result.turns, result.revision);
} catch (err) {
await loadChiefCenter();
error.value = resolveErrorMessage(err);
}
};
const shiftTurns = async (amount: number) => {
if (!data.value || !isEditingAllowed.value) {
return;
@@ -308,6 +290,38 @@ const shiftTurns = async (amount: number) => {
error.value = resolveErrorMessage(err);
}
};
const reserveTurn = async (payload: { index: number; action: string; args: Record<string, unknown> }) => {
if (!data.value || !isEditingAllowed.value) return;
try {
const result = await trpc.turns.reserved.setNation.mutate({
generalId: data.value.me.id,
turnIndex: payload.index,
action: payload.action,
args: payload.args,
expectedRevision: selectedChief.value?.revision ?? 0,
});
updateMyTurns(result.turns, result.revision);
} catch (err) {
await loadChiefCenter();
error.value = resolveErrorMessage(err);
}
};
const repeatTurns = async (amount: number) => {
if (!data.value || !isEditingAllowed.value) return;
try {
const result = await trpc.turns.reserved.repeatNation.mutate({
generalId: data.value.me.id,
amount,
expectedRevision: selectedChief.value?.revision ?? 0,
});
updateMyTurns(result.turns, result.revision);
} catch (err) {
await loadChiefCenter();
error.value = resolveErrorMessage(err);
}
};
</script>
<template>
@@ -316,7 +330,8 @@ const shiftTurns = async (amount: number) => {
<RouterLink class="chief-nav" to="/">돌아가기</RouterLink>
<button class="chief-nav" @click="loadChiefCenter">갱신</button>
<h1>사령부</h1>
<div></div><div></div>
<div></div>
<div></div>
</header>
<div v-if="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
@@ -324,47 +339,80 @@ const shiftTurns = async (amount: number) => {
<section v-if="loading && !data" class="loading-panel"><SkeletonLines :lines="5" /></section>
<section v-else-if="data && isMobile" class="layout-mobile">
<div class="mobile-editor">
<aside class="mobile-controls legacy-bg1">
<strong>{{ selectedChief?.name ?? '-' }}</strong>
<span>{{ selectedChief ? formatOfficerLevelText(selectedChief.officerLevel, data.nation.level) : '-' }}</span>
<time>{{ selectedChiefRows[0]?.time ?? '--:--' }}</time>
<button>고급 모드</button><button>반복</button>
<button @click="shiftTurns(-1)">당기기</button><button @click="shiftTurns(1)">미루기</button>
</aside>
<div class="mobile-turns">
<div v-for="row in selectedChiefRows" :key="row.index" class="mobile-turn-row">
<time>{{ row.time }}</time><strong>{{ row.action }}</strong>
<button :disabled="!isEditingAllowed" @click="clearTurn(row.index)"></button>
</div>
</div>
<ChiefCommandEditor
v-if="isEditingAllowed && selectedChief"
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
:name="selectedChief.name"
:npc-state="selectedChief.npcState"
:rows="selectedChiefRows"
:command-table="commandTable"
:loading="commandLoading"
:mobile="true"
@reserve="reserveTurn"
@shift="shiftTurns"
@repeat="repeatTurns"
/>
<div v-else-if="selectedChief" class="mobile-readonly">
<ChiefTurnCard
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
:name="selectedChief.name"
:npc-state="selectedChief.npcState"
:rows="selectedChiefRows"
/>
</div>
<div class="chief-overview">
<ChiefTurnCard v-for="chief in chiefViews" :key="chief.officerLevel"
: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"
@select="selectedChiefLevel = chief.officerLevel" />
<div class="chief-overview-frame">
<div class="chief-overview">
<ChiefTurnCard
v-for="chief in chiefViews"
:key="chief.officerLevel"
: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>
</div>
</section>
<section v-else-if="data" class="layout-desktop">
<div class="chief-grid">
<ChiefTurnCard
v-for="chief in chiefViews"
:key="chief.officerLevel"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
:selected="chief.officerLevel === selectedChief?.officerLevel"
:is-me="chief.officerLevel === data.me.officerLevel"
:clickable="true"
@select="selectedChiefLevel = chief.officerLevel"
/>
</div>
<div v-if="isEditingAllowed" class="desktop-actions legacy-bg0">
<button @click="shiftTurns(-1)">당기기</button><button @click="shiftTurns(1)">미루기</button>
<div
v-for="(rowChiefs, rowIndex) in [chiefViews.slice(0, 4), chiefViews.slice(4, 8)]"
:key="rowIndex"
class="chief-grid-row"
>
<div class="turn-index-gutter legacy-bg0">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
<template v-for="chief in rowChiefs" :key="chief.officerLevel">
<ChiefCommandEditor
v-if="chief.officerLevel === data.me.officerLevel && data.me.officerLevel >= 5"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
:command-table="commandTable"
:loading="commandLoading"
@reserve="reserveTurn"
@shift="shiftTurns"
@repeat="repeatTurns"
/>
<ChiefTurnCard
v-else
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
/>
</template>
<div class="turn-index-gutter legacy-bg0">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
</div>
</section>
<footer class="chief-footer legacy-bg0"><RouterLink class="chief-nav" to="/">돌아가기</RouterLink></footer>
@@ -580,66 +628,165 @@ const shiftTurns = async (amount: number) => {
text-decoration: none;
cursor: pointer;
}
.layout-desktop { display: block; }
.chief-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
.layout-desktop {
display: block;
}
.chief-grid :deep(.chief-header) { height: 24px; min-height: 24px; }
.chief-grid :deep(.chief-row) { box-sizing: border-box; min-height: 30px; }
.chief-grid :deep(.chief-card) { border-color: transparent; box-shadow: none; }
.desktop-actions { padding: 2px 24px; }
.desktop-actions button,
.mobile-controls button {
min-height: 35px;
border: 0;
border-radius: 4px;
background: #444;
color: #fff;
font-weight: 700;
.chief-footer {
min-height: 56px;
padding-top: 20px;
}
.chief-footer { min-height: 56px; padding-top: 20px; }
.chief-footer .chief-nav { width: 70px; }
.mobile-editor {
height: 371px;
display: grid;
grid-template-columns: 109px 1fr;
background: #000;
.chief-footer .chief-nav {
width: 70px;
}
.mobile-controls {
display: grid;
grid-template-columns: 1fr 1fr;
align-content: start;
text-align: center;
}
.mobile-controls strong,
.mobile-controls span,
.mobile-controls time { grid-column: 1 / -1; min-height: 30px; line-height: 30px; }
.mobile-controls time { border-radius: 5px; background: #345c85; }
.mobile-controls button { grid-column: 1 / -1; margin-top: 5px; }
.mobile-turns { display: grid; grid-template-rows: repeat(12, 30px); padding-top: 10px; }
.mobile-turn-row {
display: grid;
grid-template-columns: 74px 1fr 53px;
align-items: center;
background: #071638;
text-align: center;
}
.mobile-turn-row:nth-child(even) { background: #0d214e; }
.mobile-turn-row button { height: 30px; border: 0; background: #3d3d3d; color: #fff; }
.chief-overview {
width: 445px;
margin-top: 56px;
display: grid;
grid-template-columns: repeat(4, 111.25px);
}
.chief-overview :deep(.chief-card) { border-color: transparent; box-shadow: none; }
.chief-overview :deep(.chief-row) { height: 12px; line-height: 10px; }
.chief-overview :deep(.chief-header) { height: 28px; }
@media (max-width: 1024px) {
.chief-page { width: 500px; min-width: 500px; }
.chief-top { grid-template-columns: 89px 89px 1fr 0 0; }
.chief-overview { grid-template-columns: repeat(4, 111.25px); }
.chief-page {
width: 500px;
min-width: 500px;
}
.chief-top {
grid-template-columns: 89px 89px 1fr 0 0;
}
}
/* Ref PageChiefCenter의 24 + 4×238 + 24 행렬과 500px 축소 overview 계약입니다. */
.layout-desktop {
display: block;
}
.chief-grid-row {
display: grid;
grid-template-columns: 24px repeat(4, 238px) 24px;
align-items: start;
}
.turn-index-gutter {
display: grid;
grid-template-rows: 24px repeat(12, 30px);
text-align: center;
}
.turn-index-gutter span {
display: grid;
place-items: center;
}
.chief-grid-row :deep(.chief-card) {
border: 0;
box-shadow: none;
}
.chief-grid-row :deep(.chief-header) {
box-sizing: border-box;
height: 24px;
min-height: 24px;
justify-content: center;
padding: 0;
}
.chief-grid-row :deep(.chief-title) {
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
}
.chief-grid-row :deep(.chief-level),
.chief-grid-row :deep(.chief-name) {
font-size: 14px;
font-weight: 400;
color: inherit;
}
.chief-grid-row :deep(.chief-level)::after {
content: ':';
}
.chief-grid-row :deep(.chief-row) {
box-sizing: border-box;
min-height: 30px;
height: 30px;
grid-template-columns: 55px minmax(0, 1fr);
gap: 0;
padding: 0;
border: 0;
font-size: 14px;
text-align: center;
}
.chief-grid-row :deep(.row-index) {
display: none;
}
.chief-grid-row :deep(.row-time),
.chief-grid-row :deep(.row-action) {
height: 30px;
display: grid;
place-items: center;
}
.chief-grid-row :deep(.row-time) {
background: #000;
}
.chief-grid-row :deep(.chief-row:nth-child(odd) .row-action) {
background-color: rgba(18, 41, 93, 0.88);
}
.chief-grid-row :deep(.chief-row:nth-child(even) .row-action) {
background-color: rgba(7, 22, 56, 0.88);
}
.layout-mobile {
display: flex;
flex-direction: column;
gap: 0;
}
.chief-overview-frame {
width: 500px;
height: 320px;
margin-top: 56px;
overflow: hidden;
}
.chief-overview {
width: 890px;
height: 1248px;
margin-top: 0;
display: grid;
grid-template-columns: repeat(4, 222.5px);
transform: scale(0.5);
transform-origin: left top;
}
.chief-overview :deep(.chief-card) {
width: 222.5px;
height: 624px;
border: 0;
border-left: 1px solid #fff;
box-shadow: none;
box-sizing: border-box;
}
.chief-overview :deep(.row-index) {
display: none;
}
.chief-overview :deep(.chief-row) {
grid-template-columns: 74px minmax(0, 1fr);
padding: 0;
gap: 0;
text-align: center;
font-size: 20px;
}
.chief-overview :deep(.row-time),
.chief-overview :deep(.row-action) {
display: grid;
place-items: center;
}
.mobile-readonly {
width: 308px;
min-height: 394px;
margin: 10px auto 16px;
}
.mobile-readonly :deep(.chief-header) {
height: 24px;
min-height: 24px;
}
.mobile-readonly :deep(.chief-row) {
height: 30px;
grid-template-columns: 55px 1fr;
padding: 0;
}
.mobile-readonly :deep(.row-index) {
display: none;
}
@media (max-width: 1024px) {
.chief-overview {
grid-template-columns: repeat(4, 222.5px);
}
}
</style>