플레이 감사에 장수 수치 정렬과 시점별 도시 지도를 추가한다

This commit is contained in:
2026-09-26 04:52:18 +00:00
parent e35679335c
commit 51d0317a69
7 changed files with 514 additions and 8 deletions
@@ -0,0 +1,93 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { z } from 'zod';
import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js';
import { auditProcedure, readAudit, readAuditWorld, findAuditMonth, zAuditMonth } from './shared.js';
import { citySelect, projectCurrentCity, zAuditCityData } from './projection.js';
/** 도시 표본 전체를 한 지도 단위로 읽는다. 장수/로그/trace는 읽지 않는다. */
export const cityMap = auditProcedure.input(z.object({ at: zAuditMonth.optional() }).strict()).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
if (input.at && !sample)
return {
...world,
collected: false,
sample,
map: null,
layout: null,
unmappedCityIds: [],
nextCursor: null,
};
const state = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { config: true } });
const environment = asRecord(asRecord(state?.config).environment);
const layout =
typeof environment.mapName === 'string' && environment.mapName.trim()
? await loadMapLayoutByName(environment.mapName)
: await loadMapLayout(ctx.profile.scenario);
const cities = sample
? (
await tx.playAuditCity.findMany({
where: { sampleId: sample.id },
select: { data: true },
take: 1025,
})
).map((row) => zAuditCityData.parse(row.data))
: (await tx.city.findMany({ select: citySelect, take: 1025 })).map(projectCurrentCity);
if (cities.length > 1024)
throw new TRPCError({
code: 'BAD_REQUEST',
message: '지도 조회 범위를 초과했습니다. 도시 목록으로 조회해 주세요.',
});
const nationIds = [...new Set(cities.map((city) => city.nationId))];
const identity = z.object({ id: z.number(), name: z.string(), color: z.string() });
const nations = sample
? (
await tx.playAuditNation.findMany({
where: { sampleId: sample.id, nationId: { in: nationIds } },
select: { data: true },
})
).map((row) => ({ ...identity.parse(row.data), capitalCityId: 0 }))
: await tx.nation.findMany({
where: { id: { in: nationIds } },
select: { id: true, name: true, color: true, capitalCityId: true },
});
const byCity = new Map(cities.map((city) => [city.id, city]));
const byNation = new Map(nations.map((nation) => [nation.id, nation]));
const layoutIds = new Set(layout.cityList.map((city) => city.id));
// 없는 표본을 무주 도시로 그리지 않는다. 당시 이름과 소유만 사용한다.
const visibleLayout = {
...layout,
cityList: layout.cityList
.filter((city) => byCity.has(city.id))
.map((city) => ({ ...city, name: byCity.get(city.id)!.name })),
};
return {
...world,
collected: true,
sample,
layout: visibleLayout,
nextCursor: null,
unmappedCityIds: cities.filter((city) => !layoutIds.has(city.id)).map((city) => city.id),
map: {
year: input.at?.year ?? world.year,
month: input.at?.month ?? world.month,
startYear: world.startYear,
cityList: visibleLayout.cityList.map((layoutCity): [number, number, number, number, number, number] => {
const city = byCity.get(layoutCity.id)!;
return [city.id, city.level, city.state, city.nationId, layoutCity.region, city.supplyState];
}),
nationList: nationIds.map((id): [number, string, string, number] => {
const nation = byNation.get(id);
return [
id,
nation?.name ?? (id === 0 ? '무주' : `국가 #${id} (미수집)`),
nation?.color ?? '#888888',
nation?.capitalCityId ?? 0,
];
}),
},
};
})
);
@@ -0,0 +1,108 @@
import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra';
import { z } from 'zod';
import { generalSelect, projectCurrentGeneral, zAuditGeneralData } from './projection.js';
export const zGeneralSort = z.enum([
'id',
'gold',
'rice',
'crew',
'train',
'atmos',
'leadership',
'strength',
'intelligence',
'experience',
'dedication',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
]);
export const zGeneralCursor = z.object({ id: z.number().int().nonnegative(), value: z.number().nullable() }).strict();
const columns: Record<z.infer<typeof zGeneralSort>, string> = {
id: 'id',
gold: 'gold',
rice: 'rice',
crew: 'crew',
train: 'train',
atmos: 'atmos',
leadership: 'leadership',
strength: 'strength',
intelligence: 'intel',
experience: 'experience',
dedication: 'dedication',
dex1: 'dex1',
dex2: 'dex2',
dex3: 'dex3',
dex4: 'dex4',
dex5: 'dex5',
};
export const readSortedGenerals = async (
tx: GamePrisma.TransactionClient,
input: {
sort: z.infer<typeof zGeneralSort>;
order: 'asc' | 'desc';
limit: number;
cursor?: number | z.infer<typeof zGeneralCursor>;
nationId?: number;
cityId?: number;
population?: 'human' | 'npc' | 'troopNpc';
name?: string;
},
sampleId?: string
) => {
if (typeof input.cursor === 'number')
throw new TRPCError({ code: 'BAD_REQUEST', message: '정렬 조건에 맞는 다음 페이지를 선택해 주세요.' });
// SQL 식별자/JSON 경로는 서버 allowlist에서만 조립한다. 사용자 문자열은 parameter다.
const id = GamePrisma.raw(sampleId ? 'general_id' : 'id');
const path = ['leadership', 'strength', 'intelligence'].includes(input.sort)
? `stats,${input.sort}`
: input.sort.startsWith('dex')
? `dex,${input.sort}`
: input.sort;
const metric = sampleId
? GamePrisma.raw(`(data #>> '{${path}}')::double precision`)
: input.sort.startsWith('dex')
? GamePrisma.raw(
`CASE WHEN jsonb_typeof(meta->'${input.sort}') = 'number' THEN (meta->>'${input.sort}')::double precision ELSE 0 END`
)
: GamePrisma.raw(columns[input.sort]);
const filters: GamePrisma.Sql[] = [];
if (sampleId) filters.push(GamePrisma.sql`sample_id = ${sampleId}`);
if (input.nationId !== undefined) filters.push(GamePrisma.sql`nation_id = ${input.nationId}`);
if (input.cityId !== undefined) filters.push(GamePrisma.sql`city_id = ${input.cityId}`);
if (input.population === 'human') filters.push(GamePrisma.sql`npc_state < 2`);
if (input.population === 'npc') filters.push(GamePrisma.sql`npc_state >= 2 AND npc_state <> 5`);
if (input.population === 'troopNpc') filters.push(GamePrisma.sql`npc_state = 5`);
if (input.name)
filters.push(
GamePrisma.sql`${GamePrisma.raw(sampleId ? "data->>'name'" : 'name')} LIKE ${`%${input.name.replace(/[\\%_]/g, '\\$&')}%`}`
);
if (input.cursor) {
const { value, id: cursorId } = input.cursor;
filters.push(
value === null
? GamePrisma.sql`(${metric} IS NULL AND ${id} > ${cursorId})`
: GamePrisma.sql`(${metric} ${GamePrisma.raw(input.order === 'asc' ? '>' : '<')} ${value} OR (${metric} = ${value} AND ${id} > ${cursorId}) OR ${metric} IS NULL)`
);
}
const rows = await tx.$queryRaw<{ id: number; value: number | null; data: unknown }[]>(GamePrisma.sql`
SELECT ${id} AS id, ${metric} AS value, ${sampleId ? GamePrisma.raw('data') : GamePrisma.sql`NULL`} AS data
FROM ${GamePrisma.raw(sampleId ? 'play_audit_general' : 'general')}
WHERE ${filters.length ? GamePrisma.join(filters, ' AND ') : GamePrisma.sql`TRUE`}
ORDER BY ${metric} ${GamePrisma.raw(input.order.toUpperCase())} NULLS LAST, ${id} ASC LIMIT ${input.limit + 1}
`);
const page = rows.slice(0, input.limit);
const current = sampleId
? []
: await tx.general.findMany({ where: { id: { in: page.map((row) => row.id) } }, select: generalSelect });
const byId = new Map(current.map((row) => [row.id, projectCurrentGeneral(row)]));
return {
items: page.map((row) => (sampleId ? zAuditGeneralData.parse(row.data) : byId.get(row.id)!)),
nextCursor:
rows.length > input.limit ? { id: page[page.length - 1]!.id, value: page[page.length - 1]!.value } : null,
};
};
@@ -1,3 +1,6 @@
import { cityMap } from './cityMap.js';
import { TRPCError } from '@trpc/server';
import { readSortedGenerals, zGeneralSort, zGeneralCursor } from './generalSort.js';
import { requestState } from './requests.js'; import { requestState } from './requests.js';
import { decisionHistory, decisionDetail } from './decisions.js'; import { decisionHistory, decisionDetail } from './decisions.js';
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js'; import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
@@ -27,6 +30,7 @@ import {
} from './projection.js'; } from './projection.js';
export const playAuditRouter = router({ export const playAuditRouter = router({
cityMap,
requestState, requestState,
decisionHistory, decisionHistory,
decisionDetail, decisionDetail,
@@ -161,11 +165,20 @@ export const playAuditRouter = router({
population: z.enum(['human', 'npc', 'troopNpc']).optional(), population: z.enum(['human', 'npc', 'troopNpc']).optional(),
name: z.string().trim().max(64).optional(), name: z.string().trim().max(64).optional(),
order: z.enum(['asc', 'desc']).default('asc'), order: z.enum(['asc', 'desc']).default('asc'),
sort: zGeneralSort.default('id'),
cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(),
}) })
) )
.query(({ ctx, input }) => .query(({ ctx, input }) =>
readAudit(ctx, async (tx) => { readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx); const world = await readAuditWorld(tx);
if (input.sort !== 'id') {
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
if (input.at && !sample) return { ...world, sample, collected: false, items: [], nextCursor: null };
return { ...world, sample, collected: true, ...(await readSortedGenerals(tx, input, sample?.id)) };
}
if (typeof input.cursor === 'object')
throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 번호 정렬의 다음 페이지가 아닙니다.' });
const npcState = const npcState =
input.population === 'human' input.population === 'human'
? { lt: 2 } ? { lt: 2 }
@@ -2852,6 +2852,132 @@ integration('game API security over HTTP transport', () => {
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 } },
}); });
// 현재/과거 정렬은 전체 조건 안에서 수행하고 동점은 ID로 안정적으로 넘긴다.
const originalSortRows = await db.general.findMany({
where: { id: { in: [generalId, sameNationGeneralId] } },
select: { id: true, gold: true },
});
await db.general.updateMany({
where: { id: { in: [generalId, sameNationGeneralId] } },
data: { gold: 54321 },
});
const sortedInput = { nationId: ownerNationId, sort: 'gold', order: 'desc', limit: 1 };
expect((await get('generals', admin, sortedInput)).body).toMatchObject({
result: {
data: {
items: [{ id: generalId, gold: 54321 }],
nextCursor: { id: generalId, value: 54321 },
},
},
});
expect(
(await get('generals', admin, { ...sortedInput, cursor: { id: generalId, value: 54321 } })).body
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }] } } });
for (const row of originalSortRows)
await db.general.update({ where: { id: row.id }, data: { gold: row.gold } });
for (const sort of [
'gold',
'rice',
'crew',
'train',
'atmos',
'leadership',
'strength',
'intelligence',
'experience',
'dedication',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
]) {
for (const at of [undefined, { year: 190, month: 1 }]) {
expect((await get('generals', admin, { sort, at })).status).toBe(200);
}
}
const oldGeneral = await db.playAuditGeneral.findUniqueOrThrow({
where: { sampleId_generalId: { sampleId, generalId } },
});
await db.playAuditGeneral.create({
data: {
sampleId,
generalId: sameNationGeneralId,
nationId: oldGeneral.nationId,
cityId: oldGeneral.cityId,
npcState: oldGeneral.npcState,
data: { ...asRecord(oldGeneral.data), id: sameNationGeneralId, name: '동점과거장수' },
},
});
const historicalValue = past.gold;
expect(
(await get('generals', admin, { sort: 'gold', order: 'desc', at: { year: 190, month: 1 }, limit: 1 }))
.body
).toMatchObject({
result: { data: { items: [{ id: generalId }], nextCursor: { id: generalId, value: historicalValue } } },
});
expect(
(
await get('generals', admin, {
sort: 'gold',
order: 'desc',
at: { year: 190, month: 1 },
limit: 1,
cursor: { id: generalId, value: historicalValue },
})
).body
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }], nextCursor: null } } });
await db.playAuditGeneral.delete({
where: { sampleId_generalId: { sampleId, generalId: sameNationGeneralId } },
});
expect((await get('generals', admin, { sort: 'gold', cursor: 1 })).status).toBe(400);
expect((await get('generals', admin, { sort: 'gold; DROP TABLE general' })).status).toBe(400);
expect(
(await get('generals', admin, { sort: 'gold', at: { year: 190, month: 1 }, name: '%' })).body
).toMatchObject({ result: { data: { items: [] } } });
for (const accessToken of [
undefined,
await token(['admin']),
await token(['admin.playAudit.read:other:default']),
]) {
expect((await get('cityMap', accessToken, {})).status).toBe(accessToken ? 403 : 401);
expect((await get('generals', accessToken, { sort: 'crew' })).status).toBe(accessToken ? 403 : 401);
}
const citySource = await db.playAuditCity.findFirstOrThrow({ where: { sampleId }, select: { data: true } });
await db.playAuditCity.create({
data: {
sampleId,
cityId: 1,
nationId: ownerNationId,
data: { ...asRecord(citySource.data), id: 1, name: '지도과거도시' },
},
});
const map = await get('cityMap', admin, { at: { year: 190, month: 1 } });
expect(map.status).toBe(200);
expect(map.body).toMatchObject({
result: {
data: {
collected: true,
map: {
year: 190,
month: 1,
cityList: expect.arrayContaining([[1, 4, 0, ownerNationId, expect.any(Number), 1]]),
},
layout: {
cityList: expect.arrayContaining([
expect.objectContaining({ id: 1, name: '지도과거도시' }),
]),
},
},
},
});
expect(JSON.stringify(map.body)).not.toContain('must-not-expose');
await db.playAuditCity.delete({ where: { sampleId_cityId: { sampleId, cityId: 1 } } });
expect((await get('cityMap', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
result: { data: { collected: false, map: null } },
});
expect((await get('cityMap', admin, {})).status).toBe(200);
expect((await get('cityMap', admin, { at: { year: 9999, month: 1 } })).status).toBe(400);
const first = await get('generals', admin, { limit: 1 }); const first = await get('generals', admin, { limit: 1 });
expect(first.status).toBe(200); expect(first.status).toBe(200);
expect(first.body).toMatchObject({ expect(first.body).toMatchObject({
+67 -1
View File
@@ -546,7 +546,11 @@ const install = async (
...world, ...world,
collected: !input.at || (input.at as { month: number }).month !== 7, collected: !input.at || (input.at as { month: number }).month !== 7,
sample: input.at ?? null, sample: input.at ?? null,
nextCursor: input.cursor ? null : 1, nextCursor: input.cursor
? null
: input.sort && input.sort !== 'id'
? { id: 1, value: 1200 }
: 1,
items: items:
input.at && (input.at as { month: number }).month === 7 input.at && (input.at as { month: number }).month === 7
? [] ? []
@@ -567,6 +571,27 @@ const install = async (
nation: { id: 2, name: '촉' }, nation: { id: 2, name: '촉' },
city: { id: 3, name: '성도' }, city: { id: 3, name: '성도' },
}); });
case 'playAudit.cityMap':
return result({
...world,
collected: true,
sample: input.at ?? null,
nextCursor: null,
unmappedCityIds: [],
layout: {
mapName: 'che',
cityList: [{ id: 3, name: '성도', level: 4, region: 1, x: 200, y: 180, path: [] }],
regionMap: { 1: '익주' },
levelMap: { 4: '이' },
},
map: {
year: 190,
month: (input.at as { month: number } | undefined)?.month ?? 7,
startYear: 190,
cityList: [[3, 4, 0, 2, 1, 1]],
nationList: [[2, '촉', '#ff0000', 0]],
},
});
case 'playAudit.cityDetail': case 'playAudit.cityDetail':
return result({ return result({
...world, ...world,
@@ -1455,3 +1480,44 @@ for (const width of [1440, 390]) {
if (width < 1200) await expect(detail).toBeInViewport(); if (width < 1200) await expect(detail).toBeInViewport();
}); });
} }
for (const width of [1280, 390]) {
test(`audit historical map and ranked generals at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.route('https://sam-image.hided.net/**', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="700" height="500"><rect width="700" height="500" fill="#203c2d"/></svg>',
})
);
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6'));
expect(requests.some((request) => request.operation === 'playAudit.cityMap')).toBe(false);
await page.getByRole('link', { name: '도시 지도', exact: true }).click();
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
await page.getByRole('button', { name: '성도', exact: true }).click();
await expect(page).toHaveURL(/cityRecord=3/);
await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible();
await capture(page, `audit-map-${width}`);
await page.reload();
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click();
await expect(page).toHaveURL(/city=3/);
await page.getByLabel('정렬 기준', { exact: true }).selectOption('gold');
await page.getByLabel('정렬 방향', { exact: true }).selectOption('desc');
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect(page).toHaveURL(/sort=gold/);
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({
sort: 'gold',
order: 'desc',
cityId: 3,
at: { year: 190, month: 6, kind: 'MONTH_END' },
cursor: { id: 1, value: 1200 },
});
await page.reload();
await expect(page.getByLabel('정렬 기준', { exact: true })).toHaveValue('gold');
await capture(page, `audit-ranked-${width}`);
});
}
+82 -3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'; import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue';
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';
@@ -24,6 +25,27 @@ const coverage = ref<Coverage | null>(null);
const nations = ref<Nations | null>(null); const nations = ref<Nations | null>(null);
const generals = ref<Generals | null>(null); const generals = ref<Generals | null>(null);
const cities = ref<Cities | null>(null); const cities = ref<Cities | null>(null);
const cityMap = ref<Awaited<ReturnType<typeof trpc.playAudit.cityMap.query>> | null>(null);
const cityView = computed(() => (route.query.view === 'map' ? 'map' : 'list'));
const sortOptions = [
['id', '장수 번호'],
['gold', '금'],
['rice', '쌀'],
['crew', '병력'],
['train', '훈련'],
['atmos', '사기'],
['leadership', '통솔'],
['strength', '무력'],
['intelligence', '지력'],
['experience', '경험'],
['dedication', '공헌'],
['dex1', '보병 숙련'],
['dex2', '궁병 숙련'],
['dex3', '기병 숙련'],
['dex4', '귀병 숙련'],
['dex5', '차병 숙련'],
] as const;
const generalSort = ref<(typeof sortOptions)[number][0]>('id');
const series = ref<Series | null>(null); const series = ref<Series | null>(null);
const nationSnapshot = ref<NationSnapshot | null>(null); const nationSnapshot = ref<NationSnapshot | null>(null);
const authorized = ref(false); const authorized = ref(false);
@@ -151,7 +173,9 @@ const result = computed(() =>
tab.value === 'generals' tab.value === 'generals'
? generals.value ? generals.value
: tab.value === 'cities' : tab.value === 'cities'
? cities.value ? cityView.value === 'map'
? cityMap.value
: cities.value
: nationSnapshot.value : nationSnapshot.value
? { ...nationSnapshot.value, nextCursor: null } ? { ...nationSnapshot.value, nextCursor: null }
: series.value : series.value
@@ -231,6 +255,7 @@ const readQuery = () => {
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : ''; cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
generalName.value = typeof route.query.name === 'string' ? route.query.name : ''; generalName.value = typeof route.query.name === 'string' ? route.query.name : '';
generalOrder.value = route.query.order === 'desc' ? 'desc' : 'asc'; generalOrder.value = route.query.order === 'desc' ? 'desc' : 'asc';
generalSort.value = sortOptions.find(([key]) => key === route.query.sort)?.[0] ?? 'id';
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population)) population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
? String(route.query.population) ? String(route.query.population)
: ''; : '';
@@ -254,6 +279,7 @@ const load = async (append = false) => {
if (!append) { if (!append) {
generals.value = null; generals.value = null;
cities.value = null; cities.value = null;
cityMap.value = null;
series.value = null; series.value = null;
nationSnapshot.value = null; nationSnapshot.value = null;
} }
@@ -265,6 +291,7 @@ const load = async (append = false) => {
cityId: cityId.value === '' ? undefined : Number(cityId.value), cityId: cityId.value === '' ? undefined : Number(cityId.value),
name: generalName.value.trim() || undefined, name: generalName.value.trim() || undefined,
order: generalOrder.value, order: generalOrder.value,
sort: generalSort.value,
population: population:
population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc' population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc'
? population.value ? population.value
@@ -276,6 +303,9 @@ const load = async (append = false) => {
...response, ...response,
items: append ? [...(generals.value?.items ?? []), ...response.items] : response.items, items: append ? [...(generals.value?.items ?? []), ...response.items] : response.items,
}; };
} else if (tab.value === 'cities' && cityView.value === 'map') {
const response = await trpc.playAudit.cityMap.query({ at: at.value });
if (request === generation) cityMap.value = response;
} else if (tab.value === 'cities') { } else if (tab.value === 'cities') {
const response = await trpc.playAudit.cities.query({ const response = await trpc.playAudit.cities.query({
...filter, ...filter,
@@ -337,6 +367,8 @@ const apply = async () => {
population: population.value || undefined, population: population.value || undefined,
name: tab.value === 'generals' ? generalName.value.trim() || undefined : undefined, name: tab.value === 'generals' ? generalName.value.trim() || undefined : undefined,
order: tab.value === 'generals' ? generalOrder.value : undefined, order: tab.value === 'generals' ? generalOrder.value : undefined,
sort: tab.value === 'generals' && generalSort.value !== 'id' ? generalSort.value : undefined,
view: tab.value === 'cities' ? cityView.value : undefined,
at: moment.value, at: moment.value,
year: String(year.value), year: String(year.value),
month: String(month.value), month: String(month.value),
@@ -602,14 +634,26 @@ onMounted(async () => {
</select></label </select></label
> >
<template v-if="tab === 'generals'"> <template v-if="tab === 'generals'">
<label
>정렬 기준<select
v-model="generalSort"
class="legacy-sort-select"
aria-label="정렬 기준"
>
<option v-for="[key, label] in sortOptions" :key="key" :value="key">
{{ label }}
</option>
</select></label
>
<label <label
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색" >장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
/></label> /></label>
<label <label
>장수 번호 정렬<select >{{ generalSort === 'id' ? '장수 번호 정렬' : '정렬 방향'
}}<select
class="legacy-sort-select" class="legacy-sort-select"
v-model="generalOrder" v-model="generalOrder"
aria-label="장수 번호 정렬" :aria-label="generalSort === 'id' ? '장수 번호 정렬' : '정렬 방향'"
> >
<option value="asc">오름차순</option> <option value="asc">오름차순</option>
<option value="desc">내림차순</option> <option value="desc">내림차순</option>
@@ -737,6 +781,41 @@ onMounted(async () => {
</table> </table>
</div> </div>
</template> </template>
<nav v-if="tab === 'cities'" class="menu-row" aria-label="도시 표시 방식">
<RouterLink
class="legacy-button"
:aria-current="cityView === 'list' ? 'page' : undefined"
:to="{ query: { ...route.query, view: 'list' } }"
>도시 목록</RouterLink
>
<RouterLink
class="legacy-button"
:aria-current="cityView === 'map' ? 'page' : undefined"
:to="{ query: { ...route.query, view: 'map' } }"
>도시 지도</RouterLink
>
</nav>
<template v-if="cityMap && tab === 'cities' && cityView === 'map'">
<p>
지도는 선택 시점의 모든 국가 도시를 표시합니다. 도시를 눌러 상세와 주둔 장수를 확인하세요.
</p>
<p v-if="!cityMap.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="cityMap.unmappedCityIds.length">
지도 위치가 없는 도시: {{ cityMap.unmappedCityIds.join(', ') }}. 도시 목록에서 조회할 수
있습니다.
</p>
<MapViewer
v-if="cityMap.map"
:map-data="cityMap.map"
:map-layout="cityMap.layout"
:loading="loading"
:selected-city-id="selectedCity"
:detail-mode="false"
fit-container
:show-current-city-marker="false"
@select-city="selectCity"
/>
</template>
<template v-if="cities && tab === 'cities'"> <template v-if="cities && tab === 'cities'">
<p v-if="!cities.collected">선택한 시점의 표본이 없습니다.</p> <p v-if="!cities.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!cities.items.length">조건에 맞는 도시가 없습니다.</p> <p v-else-if="!cities.items.length">조건에 맞는 도시가 없습니다.</p>
+25 -4
View File
@@ -158,7 +158,7 @@ migration head를 가리킨다. 실제 PG의55→56/빈56/no-op·기존 값/null
않고 조회 버튼으로 URL에 적용하며, 더 보기와 새로고침도 같은 필터·정렬을 유지한다. 않고 조회 버튼으로 URL에 적용하며, 더 보기와 새로고침도 같은 필터·정렬을 유지한다.
도시→모든 주둔 장수 연결에서는 이름 조건도 해제한다. 과거 검색은 sampleId로 먼저 도시→모든 주둔 장수 연결에서는 이름 조건도 해제한다. 과거 검색은 sampleId로 먼저
좁힌 뒤 당시 JSON 이름을 검사하며 현재 이름을 참조하지 않는다. 역순은 ID `< cursor`로 좁힌 뒤 당시 JSON 이름을 검사하며 현재 이름을 참조하지 않는다. 역순은 ID `< cursor`로
페이지를 잇는다. 자원·능력별 정렬은 복합 cursor와 비용 검토가 추가로 필요하다. 페이지를 잇는다. 자원·능력별 정렬은 아래 2026-09-26 보완에서 복합 cursor로 추가했다.
실제 PG120개월×1,000명 fixture에서 선택 월 PK1000행, 국가 추가 시50행으로 후보를 실제 PG120개월×1,000명 fixture에서 선택 월 PK1000행, 국가 추가 시50행으로 후보를
좁혔다. 이 한 fixture의 실행계획은 전체 COST gate나 운영 p95 증거를 대신하지 않는다. 좁혔다. 이 한 fixture의 실행계획은 전체 COST gate나 운영 p95 증거를 대신하지 않는다.
@@ -185,8 +185,8 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. Chart.js로 국
국가는 검색 가능한 목록에서 즉시 선택하며 장수·도시 클릭은 상세 영역에 focus를 옮긴다. 국가는 검색 가능한 목록에서 즉시 선택하며 장수·도시 클릭은 상세 영역에 focus를 옮긴다.
390px 모바일에서 문서 가로 넘침 없음, 390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다. 넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
월말/FINAL 장수·도시 projection을 보여주지만 지도, 월말/FINAL 장수·도시 projection과 도시 지도, 자원·능력별 정렬을 제공한다.
전투 통계, 자원·능력별 정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다. 전투 통계는 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다. 따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체 Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체
@@ -214,7 +214,7 @@ FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없
넘은 잘못된 값도 숨기지 않는다. 인자는 읽기 전용 `argumentJson` 텍스트로 반환한다. 넘은 잘못된 값도 숨기지 않는다. 인자는 읽기 전용 `argumentJson` 텍스트로 반환한다.
범용 JSON의 재귀 타입을 UI에 그대로 전달하지 않으면서 값은 생략하지 않는다. 범용 JSON의 재귀 타입을 UI에 그대로 전달하지 않으면서 값은 생략하지 않는다.
예약 조회 실패는 장수 상세를 지우지 않는다. 과거 예약 변경은 이후 사건 원장이 담당하며 예약 조회 실패는 장수 상세를 지우지 않는다. 과거 예약 변경은 이후 사건 원장이 담당하며
현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 통계와 지도·검색/정렬은 남는다. 현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 통계는 남는다. 지도·검색/정렬은 후속 보완으로 구현했다.
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로 `app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각 허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
@@ -685,3 +685,24 @@ transaction의 `world_state.meta.playAuditFlows`를 읽는다. 국가·자원·
[responsive](https://www.chartjs.org/docs/latest/configuration/responsive.html) 계약을 사용한다. [responsive](https://www.chartjs.org/docs/latest/configuration/responsive.html) 계약을 사용한다.
필요한 line 구성요소만 등록하고 resize·unmount를 처리하며 null 구간을 연결하지 않는다. 필요한 line 구성요소만 등록하고 resize·unmount를 처리하며 null 구간을 연결하지 않는다.
차트와 수치 표는 같은 응답을 사용하고 집단/지표 전환은 추가 API를 호출하지 않는다. 차트와 수치 표는 같은 응답을 사용하고 집단/지표 전환은 추가 API를 호출하지 않는다.
## 2026-09-26 원래 목표 재점검 보완
- `generals`는 금/쌀/병력/훈련/사기, 통솔/무력/지력, 경험/공헌, 병종 숙련도 5종의
서버 정렬을 현재와 월말/FINAL 모두 지원한다. 기본 ID 정렬은 기존 숫자 cursor를
유지하고 수치 정렬은 `{id, value}` cursor를 쓴다. 수치가 같으면 ID 오름차순,
NULL은 마지막이다. 검색·국가·도시·NPC 필터를 먼저 적용한 전체 집합을 정렬한다.
- SQL 식별자는 allowlist이고 검색값은 parameter이며 LIKE 특수문자는 literal 처리한다.
현재 조회는 ID/수치 조회와 projection 일괄 조회 2회, 과거는 표본 조회 1회다
(공통 world/header 조회 제외). 장수별 추가 조회나 저장 경로 변경은 없다.
- `cityMap({at?})`은 기존 감사 권한과 repeatable-read 경계에서 도시/국가 projection을
읽는다. 지도 화면을 선택할 때만 호출하며 최대 1024개 도시를 허용한다. 초과하면
오류로 목록 사용을 안내하고, 미수집 월은 빈 수집 상태로 반환한다.
- 기존 MapViewer를 사용하고 도시 클릭은 해당 시점 상세와 주둔 장수 목록으로 연결한다.
지도 소유/이름은 해당 표본을 사용하며 없는 표본을 무주 도시로 만들지 않는다.
좌표/배경은 현재 profile의 map layout이다. 물리적 지도 버전의 역사 복원은 아니다.
좌표가 없는 도시는 목록에서 확인하도록 ID를 안내한다. 지도는 모든 국가를 표시한다.
- 이 보완은 R2 수치 정렬과 R3 지도 탐색의 누락을 닫는다. 자원 이동 원장, 접속/계정 조사,
월중 전체 방문 이력, 취소 시즌 audit-only runtime, 전체 COST gate는 아직 완료하지 않았다.
원 설계 전체 P1–P6 완료로 해석하지 않는다.