feat: 플레이 감사 장수 도시 상세와 현재 예약 조회 구현
This commit is contained in:
@@ -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 { cityDetail, generalDetail, generalTurns } from './details.js';
|
||||
import { z } from 'zod';
|
||||
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
|
||||
import { router } from '../../trpc.js';
|
||||
@@ -21,6 +22,9 @@ import {
|
||||
} from './projection.js';
|
||||
|
||||
export const playAuditRouter = router({
|
||||
cityDetail,
|
||||
generalDetail,
|
||||
generalTurns,
|
||||
nationSeries,
|
||||
nationSnapshot: auditProcedure
|
||||
.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',
|
||||
settlementsComplete: true,
|
||||
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: {
|
||||
create: {
|
||||
generalId,
|
||||
nationId: current.nationId,
|
||||
cityId: current.cityId,
|
||||
cityId: 99123,
|
||||
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();
|
||||
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);
|
||||
for (const roles of [['user'], ['admin'], ['admin.audit.read'], ['admin.playAudit.read:other:default']]) {
|
||||
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 get('capabilities', admin)).body).toMatchObject({
|
||||
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);
|
||||
} finally {
|
||||
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.worldState.update({
|
||||
where: { id: fixtureWorldId },
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user