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
@@ -0,0 +1,138 @@
import { z } from 'zod';
import { auditProcedure, findAuditMonth, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
import {
citySelect,
generalSelect,
projectCurrentCity,
projectCurrentGeneral,
zAuditCityData,
zAuditGeneralData,
} from './projection.js';
const identity = z.object({ id: z.number(), name: z.string() });
const zDetail = z.object({ id: z.number().int().nonnegative(), at: zAuditMonth.optional() }).strict();
export const generalDetail = auditProcedure.input(zDetail).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
if (input.at) {
const sample = await findAuditMonth(tx, world, input.at);
const row = sample
? await tx.playAuditGeneral.findUnique({
where: { sampleId_generalId: { sampleId: sample.id, generalId: input.id } },
select: { data: true },
})
: null;
const general = row ? zAuditGeneralData.parse(row.data) : null;
const nation =
general && sample
? await tx.playAuditNation.findUnique({
where: { sampleId_nationId: { sampleId: sample.id, nationId: general.nationId } },
select: { data: true },
})
: null;
const city =
general && sample
? await tx.playAuditCity.findUnique({
where: { sampleId_cityId: { sampleId: sample.id, cityId: general.cityId } },
select: { data: true },
})
: null;
return {
...world,
sample,
collected: Boolean(sample),
general,
nation: nation ? identity.parse(nation.data) : null,
city: city ? identity.parse(city.data) : null,
};
}
const row = await tx.general.findUnique({ where: { id: input.id }, select: generalSelect });
const general = row ? projectCurrentGeneral(row) : null;
const nation = general
? await tx.nation.findUnique({ where: { id: general.nationId }, select: { id: true, name: true } })
: null;
const city = general
? await tx.city.findUnique({ where: { id: general.cityId }, select: { id: true, name: true } })
: null;
return { ...world, sample: null, collected: true, general, nation, city };
})
);
export const cityDetail = auditProcedure.input(zDetail).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
if (input.at) {
const sample = await findAuditMonth(tx, world, input.at);
const row = sample
? await tx.playAuditCity.findUnique({
where: { sampleId_cityId: { sampleId: sample.id, cityId: input.id } },
select: { data: true },
})
: null;
const city = row ? zAuditCityData.parse(row.data) : null;
const nation =
city && sample
? await tx.playAuditNation.findUnique({
where: { sampleId_nationId: { sampleId: sample.id, nationId: city.nationId } },
select: { data: true },
})
: null;
return {
...world,
sample,
collected: Boolean(sample),
city,
nation: nation ? identity.parse(nation.data) : null,
};
}
const row = await tx.city.findUnique({ where: { id: input.id }, select: citySelect });
const city = row ? projectCurrentCity(row) : null;
const nation = city
? await tx.nation.findUnique({ where: { id: city.nationId }, select: { id: true, name: true } })
: null;
return { ...world, sample: null, collected: true, city, nation };
})
);
// 현재 예약은 과거 표본과 분리한다. 잘못된 slot도 숨기지 않고 페이지로 조회한다.
export const generalTurns = auditProcedure
.input(
z
.object({
generalId: z.number().int().nonnegative(),
cursor: z.number().int().optional(),
limit: z.number().int().min(1).max(200).default(50),
})
.strict()
)
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const general = await tx.general.findUnique({ where: { id: input.generalId }, select: { id: true } });
const rows = general
? await tx.generalTurn.findMany({
where: {
generalId: input.generalId,
turnIdx: input.cursor === undefined ? undefined : { gt: input.cursor },
},
orderBy: { turnIdx: 'asc' },
take: input.limit + 1,
select: { turnIdx: true, actionCode: true, arg: true },
})
: [];
return {
...world,
currentOnly: true,
generalExists: Boolean(general),
items: rows
.slice(0, input.limit)
.map((row) => ({
turnIdx: row.turnIdx,
actionCode: row.actionCode,
argumentJson: JSON.stringify(row.arg),
})),
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.turnIdx : null,
};
})
);
@@ -1,4 +1,5 @@
import { nationSeries, zAuditNation } from './nationSeries.js'; import { nationSeries, zAuditNation } from './nationSeries.js';
import { cityDetail, generalDetail, generalTurns } from './details.js';
import { z } from 'zod'; import { z } from 'zod';
import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { canReadPlayAuditAccounts } from '@sammo-ts/common';
import { router } from '../../trpc.js'; import { router } from '../../trpc.js';
@@ -21,6 +22,9 @@ import {
} from './projection.js'; } from './projection.js';
export const playAuditRouter = router({ export const playAuditRouter = router({
cityDetail,
generalDetail,
generalTurns,
nationSeries, nationSeries,
nationSnapshot: auditProcedure nationSnapshot: auditProcedure
.input(z.object({ nationId: z.number().int().nonnegative(), at: zAuditMonth }).strict()) .input(z.object({ nationId: z.number().int().nonnegative(), at: zAuditMonth }).strict())
@@ -2197,23 +2197,90 @@ integration('game API security over HTTP transport', () => {
kind: 'MONTH_END', kind: 'MONTH_END',
settlementsComplete: true, settlementsComplete: true,
hash: 'http-fixture', hash: 'http-fixture',
cities: {
create: {
cityId: 99123,
nationId: ownerNationId,
data: {
id: 99123,
name: '과거도시',
nationId: ownerNationId,
level: 4,
state: 0,
population: 100,
populationMax: 200,
agriculture: 10,
agricultureMax: 20,
commerce: 10,
commerceMax: 20,
security: 10,
securityMax: 20,
wall: 10,
wallMax: 20,
defence: 10,
defenceMax: 20,
supplyState: 1,
frontState: 0,
trust: 80,
},
},
},
generals: { generals: {
create: { create: {
generalId, generalId,
nationId: current.nationId, nationId: current.nationId,
cityId: current.cityId, cityId: 99123,
npcState: current.npcState, npcState: current.npcState,
data: { ...past, name: '과거이름', hiddenSecret: 'must-not-expose' }, data: { ...past, cityId: 99123, name: '과거이름', hiddenSecret: 'must-not-expose' },
}, },
}, },
}, },
}); });
const admin = await token([`admin.playAudit.read:${profileName}`]);
const beforeInputs = await db.inputEvent.count(); const beforeInputs = await db.inputEvent.count();
expect((await get('generalDetail', admin, { id: generalId })).body).toMatchObject({
result: { data: { collected: true, general: { id: generalId, name: current.name } } },
});
expect(
(await get('generalDetail', admin, { id: generalId, at: { year: 190, month: 1 } })).body
).toMatchObject({
result: {
data: { collected: true, general: { name: '과거이름' }, city: { id: 99123, name: '과거도시' } },
},
});
expect(
(await get('generalDetail', admin, { id: generalId, at: { year: 190, month: 2 } })).body
).toMatchObject({ result: { data: { collected: false, general: null } } });
expect((await get('generalDetail', await token(['admin']), { id: generalId })).status).toBe(403);
expect((await get('cityDetail', admin, { id: 99123, at: { year: 190, month: 1 } })).body).toMatchObject({
result: { data: { collected: true, city: { id: 99123, name: '과거도시', population: 100 } } },
});
await db.generalTurn.createMany({
data: [9001, 9002].map((turnIdx) => ({
generalId,
turnIdx,
actionCode: '휴식',
arg: { fixture: turnIdx },
})),
});
expect((await get('generalTurns', admin, { generalId, cursor: 9000, limit: 1 })).body).toMatchObject({
result: {
data: {
currentOnly: true,
items: [{ turnIdx: 9001, actionCode: '휴식', argumentJson: '{"fixture":9001}' }],
nextCursor: 9001,
},
},
});
expect((await get('generalTurns', admin, { generalId, cursor: 9001, limit: 1 })).body).toMatchObject({
result: { data: { items: [{ turnIdx: 9002 }], nextCursor: null } },
});
expect((await get('generalTurns', admin, { generalId, at: { year: 190, month: 1 } })).status).toBe(400);
expect((await get('generalTurns', admin, { generalId, limit: 201 })).status).toBe(400);
expect((await get('capabilities')).status).toBe(401); expect((await get('capabilities')).status).toBe(401);
for (const roles of [['user'], ['admin'], ['admin.audit.read'], ['admin.playAudit.read:other:default']]) { for (const roles of [['user'], ['admin'], ['admin.audit.read'], ['admin.playAudit.read:other:default']]) {
expect((await get('capabilities', await token(roles))).status).toBe(403); expect((await get('capabilities', await token(roles))).status).toBe(403);
} }
const admin = await token([`admin.playAudit.read:${profileName}`]);
expect(await db.general.findUnique({ where: { userId: auditUserId } })).toBeNull(); expect(await db.general.findUnique({ where: { userId: auditUserId } })).toBeNull();
expect((await get('capabilities', admin)).body).toMatchObject({ expect((await get('capabilities', admin)).body).toMatchObject({
result: { data: { read: true, accounts: false } }, result: { data: { read: true, accounts: false } },
@@ -2446,6 +2513,7 @@ integration('game API security over HTTP transport', () => {
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401); await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
} finally { } finally {
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } }); await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } });
await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } }); await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } });
await db.worldState.update({ await db.worldState.update({
where: { id: fixtureWorldId }, where: { id: fixtureWorldId },
+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': case 'playAudit.cities':
return result({ return result({
...world, ...world,
@@ -316,3 +362,45 @@ test('final nation snapshot is separate from the monthly series', async ({ page
}); });
await capture(page, 'final-nation'); 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 PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue'; import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.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 { usePageExit } from '../composables/usePageExit';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -39,6 +41,29 @@ const resolution = ref<'month' | 'halfYear'>('halfYear');
let generation = 0; let generation = 0;
const numeric = (value: unknown, fallback: number) => const numeric = (value: unknown, fallback: number) =>
typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : fallback; 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(() => const at = computed(() =>
moment.value === 'current' moment.value === 'current'
? undefined ? undefined
@@ -189,6 +214,7 @@ const refresh = async () => {
for (const response of results) if (response.status === 'rejected') error.value = message(response.reason); for (const response of results) if (response.status === 'rejected') error.value = message(response.reason);
}; };
const showCityGenerals = async (id: number) => { const showCityGenerals = async (id: number) => {
readQuery();
tab.value = 'generals'; tab.value = 'generals';
cityId.value = String(id); cityId.value = String(id);
nationId.value = ''; nationId.value = '';
@@ -207,7 +233,7 @@ const moreNations = async () => {
} }
}; };
watch( watch(
() => route.fullPath, () => JSON.stringify(Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord')),
() => { () => {
if (authorized.value) { if (authorized.value) {
readQuery(); readQuery();
@@ -369,7 +395,9 @@ onMounted(async () => {
<tbody> <tbody>
<tr v-for="general in generals.items" :key="general.id"> <tr v-for="general in generals.items" :key="general.id">
<th scope="row"> <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' general.npcState < 2 ? '유저' : general.npcState === 5 ? '부대장 NPC' : 'NPC'
}} }}
</th> </th>
@@ -435,7 +463,11 @@ onMounted(async () => {
</thead> </thead>
<tbody> <tbody>
<tr v-for="city in cities.items" :key="city.id"> <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>{{ nationName(city.nationId) }}</td>
<td>{{ format(city.population) }} / {{ format(city.populationMax) }}</td> <td>{{ format(city.population) }} / {{ format(city.populationMax) }}</td>
<td> <td>
@@ -483,6 +515,19 @@ onMounted(async () => {
다시 조회 다시 조회
</button> </button>
</PanelCard> </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> </main>
</template> </template>
+13
View File
@@ -46,6 +46,19 @@ game session transfer를 사용하고 Gateway가 감사 데이터를 대신 읽
FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없음과 해당 국가 없음도 구분한다. FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없음과 해당 국가 없음도 구분한다.
최종 수집 자체의 모든 게임 종료 경로 연결은 P1의 남은 lifecycle 검증을 따른다. 최종 수집 자체의 모든 게임 종료 경로 연결은 P1의 남은 lifecycle 검증을 따른다.
`generalDetail/cityDetail`은 선택 엔티티와 관련 국가·도시의 PK만 조회한다.
현재/과거 DTO를 분리하고 과거 이름·위치 이름도 같은 표본에서 읽는다. 현재 행으로
과거의 누락을 메우지 않는다. 목록에서 이름을 누르면 URL의 `general/cityRecord`
상세를 연다. 상세 열기/닫기는 목록 조회 조건에서 제외하여 목록을 다시 읽지 않는다.
도시 상세의 주둔 연결도 적용된 월을 유지한다.
`generalTurns`는 현재 예약 전용 별도 조회이며 과거 시점을 입력받지 않는다.
상세의 버튼을 눌렀을 때만 기본50/최대200, `turnIdx` cursor로 읽는다. 정상30 slot을
넘은 잘못된 값도 숨기지 않는다. 인자는 읽기 전용 `argumentJson` 텍스트로 반환한다.
범용 JSON의 재귀 타입을 UI에 그대로 전달하지 않으면서 값은 생략하지 않는다.
예약 조회 실패는 장수 상세를 지우지 않는다. 과거 예약 변경은 이후 사건 원장이 담당하며
현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 요약/로그와 지도·검색/정렬은 남는다.
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로 `app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각 허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도 한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도