feat: 플레이 감사 장수 도시 상세와 현재 예약 조회 구현

This commit is contained in:
2026-09-16 03:45:18 +00:00
parent e1a05e27e7
commit 69a61b317c
8 changed files with 614 additions and 6 deletions
+88
View File
@@ -151,6 +151,52 @@ const install = async (page: Page, denied = false) => {
},
],
});
case 'playAudit.generalDetail':
return result({
...world,
sample: input.at ?? null,
collected: true,
general: { ...general, name: input.at ? '과거감사장수' : general.name },
nation: { id: 2, name: '촉' },
city: { id: 3, name: '성도' },
});
case 'playAudit.cityDetail':
return result({
...world,
collected: true,
sample: input.at ?? null,
nation: { id: 2, name: '촉' },
city: {
id: 3,
name: '성도',
nationId: 2,
level: 4,
state: 0,
population: 10000,
populationMax: 20000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
wall: 100,
wallMax: 200,
defence: 100,
defenceMax: 200,
supplyState: 1,
frontState: 0,
trust: 80,
},
});
case 'playAudit.generalTurns':
return result({
...world,
currentOnly: true,
generalExists: true,
items: [{ turnIdx: 0, actionCode: '휴식', argumentJson: '{}' }],
nextCursor: null,
});
case 'playAudit.cities':
return result({
...world,
@@ -316,3 +362,45 @@ test('final nation snapshot is separate from the monthly series', async ({ page
});
await capture(page, 'final-nation');
});
test('selected general reads detail on demand and separates current reservations from history', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals'));
await expect(page.getByRole('button', { name: '감사장수 (#1)', exact: true })).toBeVisible();
const before = requests.filter((request) => request.operation === 'playAudit.generals').length;
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible();
await expect(page.getByText('국가 촉 · 도시 성도 · 부대 #0', { exact: true })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.generals')).toHaveLength(before);
expect(requests.some((request) => request.operation === 'playAudit.generalTurns')).toBe(false);
await page.getByRole('button', { name: '현재 예약 명령 조회', exact: true }).click();
await expect(page.getByText(/위치 0: 휴식/)).toBeVisible();
await capture(page, 'general-detail');
await page.getByRole('button', { name: '상세 닫기', exact: true }).click();
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toHaveCount(0);
await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6'));
await expect(page.getByRole('heading', { name: '과거감사장수 (#1)' })).toBeVisible();
await expect(page.getByRole('button', { name: '현재 예약 명령 조회', exact: true })).toHaveCount(0);
expect(requests.filter((request) => request.operation === 'playAudit.generalTurns')).toHaveLength(1);
});
test('city detail is addressable without reloading the list and retains month for stationed generals', async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6'));
const button = page.getByRole('button', { name: '성도 (#3)', exact: true });
await expect(button).toBeVisible();
const before = requests.filter((request) => request.operation === 'playAudit.cities').length;
await button.click();
await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.cities')).toHaveLength(before);
await capture(page, 'city-detail-mobile');
await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click();
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({
cityId: 3,
at: { year: 190, month: 6, kind: 'MONTH_END' },
});
});
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ cityId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>();
defineEmits<{ close: []; generals: [cityId: number] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.cityDetail.query>>;
const data = ref<Detail | null>(null);
const error = ref('');
const loading = ref(false);
let generation = 0;
const load = async () => {
const request = ++generation;
loading.value = true;
error.value = '';
data.value = null;
try {
const response = await trpc.playAudit.cityDetail.query({ id: props.cityId, at: props.at });
if (request === generation) data.value = response;
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '도시 상세를 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
watch(
() => [props.cityId, props.at] as const,
() => {
void load();
},
{ immediate: true }
);
</script>
<template>
<PanelCard
title="선택 도시 상세"
:subtitle="at ? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : '월말'}` : '현재 상태'"
>
<template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">도시 조회 </p>
<p v-if="error" role="alert">{{ error }} <button class="legacy-button" @click="load">다시 조회</button></p>
<template v-if="data">
<p v-if="!data.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!data.city">선택한 시점에 해당 도시가 없습니다.</p>
<template v-else>
<h3>
{{ data.city.name }} (#{{ data.city.id }}) ·
{{ data.nation?.name ?? `국가 #${data.city.nationId}` }}
</h3>
<dl class="city-values">
<div>
<dt>인구</dt>
<dd>{{ format(data.city.population) }} / {{ format(data.city.populationMax) }}</dd>
</div>
<div>
<dt>농업</dt>
<dd>{{ format(data.city.agriculture) }} / {{ format(data.city.agricultureMax) }}</dd>
</div>
<div>
<dt>상업</dt>
<dd>{{ format(data.city.commerce) }} / {{ format(data.city.commerceMax) }}</dd>
</div>
<div>
<dt>치안</dt>
<dd>{{ format(data.city.security) }} / {{ format(data.city.securityMax) }}</dd>
</div>
<div>
<dt>성벽</dt>
<dd>{{ format(data.city.wall) }} / {{ format(data.city.wallMax) }}</dd>
</div>
<div>
<dt>수비</dt>
<dd>{{ format(data.city.defence) }} / {{ format(data.city.defenceMax) }}</dd>
</div>
<div>
<dt>민심 / 보급 / 전방 / 상태 / 규모</dt>
<dd>
{{ data.city.trust }} / {{ data.city.supplyState }} / {{ data.city.frontState }} /
{{ data.city.state }} / {{ data.city.level }}
</dd>
</div>
</dl>
<button class="legacy-button" @click="$emit('generals', data.city.id)">
시점의 모든 국가 주둔 장수
</button>
</template>
</template>
</PanelCard>
</template>
<style scoped>
h3 {
font-size: var(--sammo-font-size-normal);
font-weight: bold;
}
.city-values {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 8px 0;
}
dt {
font-weight: bold;
}
dd {
margin: 0;
}
[role='alert'] {
color: #ffb9b9;
}
</style>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ generalId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>();
defineEmits<{ close: [] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.generalDetail.query>>;
type Turns = Awaited<ReturnType<typeof trpc.playAudit.generalTurns.query>>;
const data = ref<Detail | null>(null);
const turns = ref<Turns | null>(null);
const loading = ref(false);
const turnsLoading = ref(false);
const error = ref('');
const turnsError = ref('');
let generation = 0;
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const load = async () => {
const request = ++generation;
data.value = null;
turns.value = null;
error.value = '';
turnsError.value = '';
loading.value = true;
turnsLoading.value = false;
try {
const response = await trpc.playAudit.generalDetail.query({ id: props.generalId, at: props.at });
if (request === generation) data.value = response;
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '장수 상세를 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
const loadTurns = async (more = false) => {
if (props.at || turnsLoading.value) return;
const request = generation;
turnsLoading.value = true;
turnsError.value = '';
try {
const response = await trpc.playAudit.generalTurns.query({
generalId: props.generalId,
limit: 50,
cursor: more ? (turns.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
turns.value = {
...response,
items: more ? [...(turns.value?.items ?? []), ...response.items] : response.items,
};
} catch (cause) {
if (request === generation)
turnsError.value = cause instanceof Error ? cause.message : '예약 명령을 조회하지 못했습니다.';
} finally {
if (request === generation) turnsLoading.value = false;
}
};
watch(
() => [props.generalId, props.at] as const,
() => {
void load();
},
{ immediate: true }
);
</script>
<template>
<PanelCard
title="선택 장수 상세"
:subtitle="at ? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : '월말'}` : '현재 상태'"
>
<template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">상세 조회 </p>
<p v-if="error" role="alert">{{ error }} <button class="legacy-button" @click="load">다시 조회</button></p>
<template v-if="data">
<p v-if="!data.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!data.general">선택한 시점에 해당 장수가 없습니다.</p>
<template v-else>
<h3>{{ data.general.name }} (#{{ data.general.id }})</h3>
<p>
국가 {{ data.nation?.name ?? `#${data.general.nationId}` }} · 도시
{{ data.city?.name ?? `#${data.general.cityId}` }} · 부대 #{{ data.general.troopId }}
</p>
<p>
{{ format(data.general.gold) }} · {{ format(data.general.rice) }} · 병력
{{ format(data.general.crew) }} · 훈련 {{ data.general.train }} · 사기 {{ data.general.atmos }}
</p>
<p>
통솔 {{ data.general.stats.leadership }} · 무력 {{ data.general.stats.strength }} · 지력
{{ data.general.stats.intelligence }} · 경험 {{ format(data.general.experience) }} · 공헌
{{ format(data.general.dedication) }}
</p>
<p>숙련 ( / / / / ): {{ Object.values(data.general.dex).map(format).join(' / ') }}</p>
<p v-if="at">과거 예약 명령은 월말 표본에 포함되지 않습니다.</p>
<button v-else class="legacy-button" :disabled="turnsLoading" @click="loadTurns()">
현재 예약 명령 조회
</button>
<p v-if="turnsLoading" role="status">예약 조회 </p>
<p v-if="turnsError" role="alert">{{ turnsError }}</p>
<template v-if="turns">
<p>예약 조회 시각 {{ turns.asOf }} · tick {{ turns.tick ?? '없음' }}</p>
<p v-if="!turns.generalExists">현재 해당 장수가 없습니다.</p>
<p v-else-if="!turns.items.length">저장된 예약 명령이 없습니다.</p>
<ol class="turns">
<li v-for="turn in turns.items" :key="turn.turnIdx">
위치 {{ turn.turnIdx }}: {{ turn.actionCode }} <code>{{ turn.argumentJson }}</code>
</li>
</ol>
<button
v-if="turns.nextCursor !== null"
class="legacy-button"
:disabled="turnsLoading"
@click="loadTurns(true)"
>
예약 불러오기
</button>
</template>
</template>
</template>
</PanelCard>
</template>
<style scoped>
h3 {
font-size: var(--sammo-font-size-normal);
font-weight: bold;
}
.turns {
padding-left: 24px;
}
.turns li {
overflow-wrap: anywhere;
padding: 4px 0;
}
[role='alert'] {
color: #ffb9b9;
}
</style>
+48 -3
View File
@@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue';
import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue';
import { usePageExit } from '../composables/usePageExit';
import { trpc } from '../utils/trpc';
@@ -39,6 +41,29 @@ const resolution = ref<'month' | 'halfYear'>('halfYear');
let generation = 0;
const numeric = (value: unknown, fallback: number) =>
typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : fallback;
const selectedGeneral = computed(() =>
typeof route.query.general === 'string' && /^\d+$/.test(route.query.general) ? Number(route.query.general) : null
);
const selectedAt = computed(() =>
route.query.at === 'month' || route.query.at === 'final'
? {
year: numeric(route.query.year, coverage.value?.year ?? 0),
month: numeric(route.query.month, coverage.value?.month ?? 1),
kind: route.query.at === 'final' ? ('FINAL' as const) : ('MONTH_END' as const),
}
: undefined
);
const selectedCity = computed(() =>
typeof route.query.cityRecord === 'string' && /^\d+$/.test(route.query.cityRecord)
? Number(route.query.cityRecord)
: null
);
const selectGeneral = (id: number) =>
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined } });
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined } });
const selectCity = (id: number) =>
router.push({ query: { ...route.query, cityRecord: String(id), general: undefined } });
const closeCity = () => router.push({ query: { ...route.query, cityRecord: undefined } });
const at = computed(() =>
moment.value === 'current'
? undefined
@@ -189,6 +214,7 @@ const refresh = async () => {
for (const response of results) if (response.status === 'rejected') error.value = message(response.reason);
};
const showCityGenerals = async (id: number) => {
readQuery();
tab.value = 'generals';
cityId.value = String(id);
nationId.value = '';
@@ -207,7 +233,7 @@ const moreNations = async () => {
}
};
watch(
() => route.fullPath,
() => JSON.stringify(Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord')),
() => {
if (authorized.value) {
readQuery();
@@ -369,7 +395,9 @@ onMounted(async () => {
<tbody>
<tr v-for="general in generals.items" :key="general.id">
<th scope="row">
{{ general.name }} (#{{ general.id }})<br />{{
<button class="legacy-button" @click="selectGeneral(general.id)">
{{ general.name }} (#{{ general.id }})</button
><br />{{
general.npcState < 2 ? '유저' : general.npcState === 5 ? '부대장 NPC' : 'NPC'
}}
</th>
@@ -435,7 +463,11 @@ onMounted(async () => {
</thead>
<tbody>
<tr v-for="city in cities.items" :key="city.id">
<th scope="row">{{ city.name }} (#{{ city.id }})</th>
<th scope="row">
<button class="legacy-button" @click="selectCity(city.id)">
{{ city.name }} (#{{ city.id }})
</button>
</th>
<td>{{ nationName(city.nationId) }}</td>
<td>{{ format(city.population) }} / {{ format(city.populationMax) }}</td>
<td>
@@ -483,6 +515,19 @@ onMounted(async () => {
다시 조회
</button>
</PanelCard>
<AuditGeneralDetail
v-if="authorized && selectedGeneral !== null"
:general-id="selectedGeneral"
:at="selectedAt"
@close="closeGeneral"
/>
<AuditCityDetail
v-if="authorized && selectedCity !== null"
:city-id="selectedCity"
:at="selectedAt"
@close="closeCity"
@generals="showCityGenerals"
/>
</main>
</template>