Files
core2026/app/game-frontend/src/views/ChiefCenterView.vue
T

899 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { addMinutes } from 'date-fns';
import { useRouter } from 'vue-router';
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';
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
type ChiefTurn = {
index: number;
action: string;
args: unknown;
};
type ChiefEntry = {
officerLevel: number;
name: string | null;
npcState: number | null;
turnTime: string | null;
revision: number;
turns: ChiefTurn[];
};
type ChiefCenterResponse = {
me: {
id: number;
officerLevel: number;
nationId: number;
};
nation: {
id: number;
name: string;
level: number;
};
currentYear: number;
currentMonth: number;
turnTermMinutes: number;
maxTurns: number;
chiefs: ChiefEntry[];
};
const chiefApi = trpc as unknown as {
nation: {
getChiefCenter: {
query: () => Promise<ChiefCenterResponse>;
};
};
turns: {
getCommandTable: {
query: (input: { generalId: number }) => Promise<CommandTable>;
};
};
world: {
getMap: { query: () => Promise<CommandMapData> };
getMapLayout: { query: () => Promise<CommandMapLayout> };
};
};
type TurnRow = {
index: number;
time: string;
action: string;
isRest: boolean;
args: unknown;
label: string;
actionCode: string;
};
const loading = ref(false);
const commandLoading = ref(false);
const error = ref<string | null>(null);
const data = ref<ChiefCenterResponse | null>(null);
const commandTable = ref<CommandTable | null>(null);
const worldMap = ref<CommandMapData | null>(null);
const mapLayout = ref<CommandMapLayout | null>(null);
const selectedChiefLevel = ref<number | null>(null);
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 1024px)');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const loadChiefCenter = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
data.value = await chiefApi.nation.getChiefCenter.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const loadCommandTable = async (generalId: number) => {
if (commandLoading.value) {
return;
}
commandLoading.value = true;
try {
const [nextCommandTable, nextWorldMap, nextMapLayout] = await Promise.all([
chiefApi.turns.getCommandTable.query({ generalId }),
chiefApi.world.getMap.query().catch(() => null),
chiefApi.world.getMapLayout.query().catch(() => null),
]);
commandTable.value = nextCommandTable;
worldMap.value = nextWorldMap;
mapLayout.value = nextMapLayout;
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
commandLoading.value = false;
}
};
onMounted(() => {
void loadChiefCenter();
});
watch(
() => data.value?.me.id,
(generalId) => {
if (generalId && !commandTable.value) {
void loadCommandTable(generalId);
}
}
);
watch(
() => data.value,
(snapshot) => {
if (!snapshot) {
return;
}
if (selectedChiefLevel.value !== null) {
return;
}
const preferred =
snapshot.me.officerLevel >= 5 ? snapshot.me.officerLevel : (snapshot.chiefs[0]?.officerLevel ?? null);
selectedChiefLevel.value = preferred;
}
);
const commandLabelMap = computed(() => {
const map = new Map<string, string>();
if (!commandTable.value) {
return map;
}
for (const group of commandTable.value.general) {
for (const entry of group.values) {
map.set(entry.key, entry.name);
}
}
for (const group of commandTable.value.nation) {
for (const entry of group.values) {
map.set(entry.key, entry.name);
}
}
return map;
});
const selectedChief = computed<ChiefEntry | null>(() => {
const snapshot = data.value;
if (!snapshot) {
return null;
}
const targetLevel = selectedChiefLevel.value ?? snapshot.me.officerLevel;
let match: ChiefEntry | null = null;
for (const chief of snapshot.chiefs) {
if (chief.officerLevel === targetLevel) {
match = chief;
break;
}
}
return match;
});
const isEditingAllowed = computed(() => {
if (!data.value || !selectedChief.value) {
return false;
}
return data.value.me.officerLevel >= 5 && data.value.me.officerLevel === selectedChief.value.officerLevel;
});
const buildTurnRows = (chief: ChiefEntry): TurnRow[] => {
const turnTermMinutes = data.value?.turnTermMinutes ?? 0;
const labelMap = commandLabelMap.value;
const baseTime = chief.turnTime ? new Date(chief.turnTime) : null;
return chief.turns.map((turn, idx) => {
const turnDate =
baseTime && Number.isFinite(turnTermMinutes) ? addMinutes(baseTime, idx * turnTermMinutes) : null;
const timeLabel = turnDate
? turnTermMinutes >= 5
? `${String(turnDate.getUTCHours()).padStart(2, '0')}:${String(turnDate.getUTCMinutes()).padStart(2, '0')}`
: `${String(turnDate.getUTCMinutes()).padStart(2, '0')}:${String(turnDate.getUTCSeconds()).padStart(2, '0')}`
: '--:--';
const actionLabel = labelMap.get(turn.action) ?? turn.action;
return {
index: turn.index,
time: timeLabel,
action: actionLabel,
label: actionLabel,
actionCode: turn.action,
args: turn.args,
isRest: turn.action === '휴식',
};
});
};
const chiefViews = computed(() => {
if (!data.value) {
return [] as Array<ChiefEntry & { rows: TurnRow[]; officerLevelText: string }>;
}
return data.value.chiefs.map((chief) => ({
...chief,
officerLevelText: formatOfficerLevelText(chief.officerLevel, data.value?.nation.level),
rows: buildTurnRows(chief),
}));
});
const selectedChiefRows = computed(() => {
if (!selectedChief.value) {
return [] as TurnRow[];
}
return buildTurnRows(selectedChief.value);
});
const updateMyTurns = (turns: Array<{ index: number; action: string; args?: unknown }>, revision: number) => {
if (!data.value) {
return;
}
const myLevel = data.value.me.officerLevel;
const entry = data.value.chiefs.find((chief) => chief.officerLevel === myLevel);
if (!entry) {
return;
}
entry.turns = turns.map((turn) => ({ ...turn, args: turn.args ?? {} }));
entry.revision = revision;
};
const shiftTurns = async (amount: number) => {
if (!data.value || !isEditingAllowed.value) {
return;
}
try {
const result = await trpc.turns.reserved.shiftNation.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);
}
};
const reserveTurns = async (entries: CommandPatternEntry[]) => {
if (!data.value || !isEditingAllowed.value) return;
try {
const result = await trpc.turns.reserved.setNationBulk.mutate({
generalId: data.value.me.id,
entries,
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>
<main class="chief-page">
<header class="chief-top legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button>
<button class="chief-nav" @click="loadChiefCenter">갱신</button>
<h1>사령부</h1>
<div></div>
<div></div>
</header>
<div v-if="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
<section v-if="loading && !data" class="loading-panel"><SkeletonLines :lines="5" /></section>
<section v-else-if="data && isMobile" class="layout-mobile">
<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"
:general-id="data.me.id"
:officer-level="selectedChief.officerLevel"
:mobile="true"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns"
@shift="shiftTurns"
@repeat="repeatTurns"
/>
<div v-else-if="selectedChief" class="mobile-readonly">
<div class="mobile-turn-index legacy-bg0">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
<ChiefTurnCard
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
:name="selectedChief.name"
:npc-state="selectedChief.npcState"
:rows="selectedChiefRows"
/>
<div class="mobile-turn-index legacy-bg0">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
</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>
</div>
</section>
<section v-else-if="data" class="layout-desktop">
<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"
:general-id="data.me.id"
:officer-level="chief.officerLevel"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns"
@shift="shiftTurns"
@repeat="repeatTurns"
/>
<ChiefTurnCard
v-else-if="chief.name"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
/>
<div v-else class="empty-chief-slot" aria-hidden="true"></div>
</template>
<div class="turn-index-gutter legacy-bg0">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
</div>
</section>
<div class="legacy-copy-helpers" aria-hidden="true">
<template v-for="idx in 8" :key="idx">
<button type="button" tabindex="-1">복사하기</button>
<button type="button" tabindex="-1">텍스트 복사</button>
</template>
</div>
<footer class="chief-footer legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button>
</footer>
</main>
</template>
<style scoped>
.layout-desktop {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(240px, 1fr);
gap: 0;
}
.chief-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
}
.chief-side {
display: flex;
flex-direction: column;
gap: 12px;
}
.layout-mobile {
display: flex;
flex-direction: column;
gap: 0;
}
.chief-overview {
display: grid;
grid-template-columns: repeat(4, 125px);
gap: 0;
}
.command-selected {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
display: grid;
gap: 6px;
font-size: 0.75rem;
margin-top: 12px;
}
.command-selected .label {
color: rgba(232, 221, 196, 0.6);
}
.command-selected .meta {
display: flex;
gap: 8px;
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.turn-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.turn-actions button {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 4px 8px;
font-size: 0.75rem;
background: rgba(12, 12, 12, 0.6);
color: inherit;
cursor: pointer;
}
.turn-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.turn-item {
border: 1px solid rgba(201, 164, 90, 0.25);
padding: 8px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
font-size: 0.75rem;
}
.turn-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.turn-index {
color: rgba(232, 221, 196, 0.6);
}
.turn-time {
font-variant-numeric: tabular-nums;
}
.turn-action {
font-weight: 600;
}
.turn-buttons {
display: flex;
gap: 6px;
}
.turn-buttons button {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 4px 8px;
font-size: 0.7rem;
background: rgba(12, 12, 12, 0.6);
color: inherit;
cursor: pointer;
}
.turn-buttons .ghost {
background: rgba(16, 16, 16, 0.6);
}
.muted {
color: rgba(232, 221, 196, 0.6);
}
@media (max-width: 1024px) {
.chief-page {
box-sizing: border-box;
width: 500px;
min-width: 500px;
padding: 0;
gap: 0;
}
.chief-overview {
grid-template-columns: repeat(4, 125px);
}
.turn-list {
gap: 0;
margin-top: 4px;
}
.turn-item {
min-height: 24px;
flex-wrap: nowrap;
align-items: center;
gap: 2px;
padding: 0 4px;
}
.turn-info {
flex: 1;
flex-wrap: nowrap;
gap: 5px;
}
.turn-buttons {
gap: 2px;
}
.turn-buttons button {
min-height: 20px;
padding: 1px 5px;
}
}
@media (min-width: 1024.01px) {
.chief-page {
box-sizing: border-box;
width: 1000px;
max-width: 1000px;
min-width: 1000px;
margin: 0 auto;
padding: 0;
gap: 0;
}
}
.chief-page {
margin: 0 auto;
color: #fff;
font: 14px/21px var(--sammo-font-sans);
}
.chief-top {
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
background-color: transparent;
}
.chief-top h1 {
margin: 0;
font-size: 24px;
font-weight: 400;
line-height: 32px;
text-align: center;
}
.chief-nav {
box-sizing: border-box;
height: 32px;
margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid;
place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.layout-desktop {
display: block;
}
.chief-footer {
min-height: 56px;
padding-top: 20px;
}
.chief-footer .chief-nav {
width: 70px;
}
@media (max-width: 1024px) {
.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;
background-color: transparent;
font-size: 16.8px;
line-height: 14.7px;
}
.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: 16.8px;
font-weight: 400;
line-height: 14.7px;
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: 40px 198px;
gap: 0;
padding: 0;
border: 0;
font-size: 14px;
line-height: 14.7px;
color: #fff;
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;
line-height: 30px;
}
.chief-grid-row :deep(.row-time) {
background: #000;
}
.chief-grid-row :deep(.chief-row:nth-child(odd)) {
background-color: rgb(12 26 65);
}
.chief-grid-row :deep(.chief-row:nth-child(even)) {
background-color: rgb(7 22 56);
}
.chief-grid-row :deep(.chief-row:nth-child(odd) .row-action) {
background-color: rgb(12 26 65);
}
.chief-grid-row :deep(.chief-row:nth-child(even) .row-action) {
background-color: rgb(7 22 56);
}
.layout-mobile {
display: flex;
flex-direction: column;
gap: 0;
}
.chief-overview-frame {
width: 500px;
height: 310px;
margin-top: -3px;
margin-bottom: 11px;
overflow: hidden;
}
.chief-overview {
width: 445px;
height: 310px;
margin-top: 0;
display: grid;
grid-template-columns: repeat(4, 111.25px);
grid-auto-rows: 155px;
}
.chief-overview :deep(.chief-card) {
width: 111.25px;
height: 155px;
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-card.compact .chief-row) {
grid-template-columns: 38px minmax(0, 1fr);
height: 11.25px !important;
min-height: 0;
padding: 0;
gap: 0;
text-align: center;
font-size: 0.55rem;
line-height: 11.25px !important;
}
.chief-overview :deep(.chief-card.compact .chief-header) {
box-sizing: border-box;
height: 20px !important;
min-height: 20px !important;
grid-template-rows: none;
}
.chief-overview :deep(.row-time),
.chief-overview :deep(.row-action) {
display: grid;
place-items: center;
}
.mobile-readonly {
width: 404px;
height: 420px;
margin: 10px 0 0 96px;
display: grid;
grid-template-columns: 24px 260px 24px 96px;
overflow: hidden;
}
.mobile-readonly :deep(.chief-card) {
width: 260px;
}
.mobile-readonly :deep(.chief-header) {
height: 24px;
min-height: 24px;
justify-content: center;
padding: 0;
background-color: transparent;
font-size: 16.8px;
line-height: 14.7px;
}
.mobile-readonly :deep(.chief-title) {
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
}
.mobile-readonly :deep(.chief-level),
.mobile-readonly :deep(.chief-name) {
font-size: 16.8px;
font-weight: 400;
line-height: 14.7px;
color: inherit;
}
.mobile-readonly :deep(.chief-level)::after {
content: ':';
}
.mobile-readonly :deep(.chief-row) {
height: 30px;
grid-template-columns: 43.33px 216.67px;
padding: 0;
font-size: 14px;
line-height: 14.7px;
color: #fff;
}
.mobile-readonly :deep(.chief-row:nth-child(odd)) {
background-color: rgb(12 26 65);
}
.mobile-readonly :deep(.chief-row:nth-child(even)) {
background-color: rgb(7 22 56);
}
.mobile-readonly :deep(.row-time),
.mobile-readonly :deep(.row-action) {
height: 30px;
display: grid;
place-items: center;
line-height: 30px;
}
.mobile-readonly :deep(.row-time) {
background-color: #000;
}
.mobile-readonly :deep(.chief-row:nth-child(odd) .row-action) {
background-color: rgb(12 26 65);
}
.mobile-readonly :deep(.chief-row:nth-child(even) .row-action) {
background-color: rgb(7 22 56);
}
.mobile-readonly :deep(.row-index) {
display: none;
}
.mobile-turn-index {
display: grid;
grid-template-rows: 24px repeat(12, 30px);
text-align: center;
}
.mobile-turn-index span {
display: grid;
place-items: center;
}
.legacy-copy-helpers {
display: none;
}
@media (max-width: 1024px) {
.chief-overview {
grid-template-columns: repeat(4, 111.25px);
}
}
.empty-chief-slot {
min-width: 0;
background-color: #071638;
background-image: var(--sammo-texture-blue);
}
.chief-grid-row > .empty-chief-slot {
height: 384px;
}
.chief-overview > .empty-chief-slot {
width: 111.25px;
height: 155px;
}
</style>