플레이 감사에 장수 수치 정렬과 시점별 도시 지도를 추가한다
This commit is contained in:
@@ -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 { decisionHistory, decisionDetail } from './decisions.js';
|
||||
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||
@@ -27,6 +30,7 @@ import {
|
||||
} from './projection.js';
|
||||
|
||||
export const playAuditRouter = router({
|
||||
cityMap,
|
||||
requestState,
|
||||
decisionHistory,
|
||||
decisionDetail,
|
||||
@@ -161,11 +165,20 @@ export const playAuditRouter = router({
|
||||
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
|
||||
name: z.string().trim().max(64).optional(),
|
||||
order: z.enum(['asc', 'desc']).default('asc'),
|
||||
sort: zGeneralSort.default('id'),
|
||||
cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(),
|
||||
})
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (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 =
|
||||
input.population === 'human'
|
||||
? { lt: 2 }
|
||||
|
||||
@@ -2852,6 +2852,132 @@ integration('game API security over HTTP transport', () => {
|
||||
expect((await get('capabilities', admin)).body).toMatchObject({
|
||||
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 });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body).toMatchObject({
|
||||
|
||||
@@ -546,7 +546,11 @@ const install = async (
|
||||
...world,
|
||||
collected: !input.at || (input.at as { month: number }).month !== 7,
|
||||
sample: input.at ?? null,
|
||||
nextCursor: input.cursor ? null : 1,
|
||||
nextCursor: input.cursor
|
||||
? null
|
||||
: input.sort && input.sort !== 'id'
|
||||
? { id: 1, value: 1200 }
|
||||
: 1,
|
||||
items:
|
||||
input.at && (input.at as { month: number }).month === 7
|
||||
? []
|
||||
@@ -567,6 +571,27 @@ const install = async (
|
||||
nation: { id: 2, 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':
|
||||
return result({
|
||||
...world,
|
||||
@@ -1455,3 +1480,44 @@ for (const width of [1440, 390]) {
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
|
||||
import MapViewer from '../components/main/MapViewer.vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import AuditNationSeries from '../components/playAudit/AuditNationSeries.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 generals = ref<Generals | 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 nationSnapshot = ref<NationSnapshot | null>(null);
|
||||
const authorized = ref(false);
|
||||
@@ -151,7 +173,9 @@ const result = computed(() =>
|
||||
tab.value === 'generals'
|
||||
? generals.value
|
||||
: tab.value === 'cities'
|
||||
? cities.value
|
||||
? cityView.value === 'map'
|
||||
? cityMap.value
|
||||
: cities.value
|
||||
: nationSnapshot.value
|
||||
? { ...nationSnapshot.value, nextCursor: null }
|
||||
: series.value
|
||||
@@ -231,6 +255,7 @@ const readQuery = () => {
|
||||
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
|
||||
generalName.value = typeof route.query.name === 'string' ? route.query.name : '';
|
||||
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))
|
||||
? String(route.query.population)
|
||||
: '';
|
||||
@@ -254,6 +279,7 @@ const load = async (append = false) => {
|
||||
if (!append) {
|
||||
generals.value = null;
|
||||
cities.value = null;
|
||||
cityMap.value = null;
|
||||
series.value = null;
|
||||
nationSnapshot.value = null;
|
||||
}
|
||||
@@ -265,6 +291,7 @@ const load = async (append = false) => {
|
||||
cityId: cityId.value === '' ? undefined : Number(cityId.value),
|
||||
name: generalName.value.trim() || undefined,
|
||||
order: generalOrder.value,
|
||||
sort: generalSort.value,
|
||||
population:
|
||||
population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc'
|
||||
? population.value
|
||||
@@ -276,6 +303,9 @@ const load = async (append = false) => {
|
||||
...response,
|
||||
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') {
|
||||
const response = await trpc.playAudit.cities.query({
|
||||
...filter,
|
||||
@@ -337,6 +367,8 @@ const apply = async () => {
|
||||
population: population.value || undefined,
|
||||
name: tab.value === 'generals' ? generalName.value.trim() || undefined : 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,
|
||||
year: String(year.value),
|
||||
month: String(month.value),
|
||||
@@ -602,14 +634,26 @@ onMounted(async () => {
|
||||
</select></label
|
||||
>
|
||||
<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
|
||||
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
|
||||
/></label>
|
||||
<label
|
||||
>장수 번호 정렬<select
|
||||
>{{ generalSort === 'id' ? '장수 번호 정렬' : '정렬 방향'
|
||||
}}<select
|
||||
class="legacy-sort-select"
|
||||
v-model="generalOrder"
|
||||
aria-label="장수 번호 정렬"
|
||||
:aria-label="generalSort === 'id' ? '장수 번호 정렬' : '정렬 방향'"
|
||||
>
|
||||
<option value="asc">오름차순</option>
|
||||
<option value="desc">내림차순</option>
|
||||
@@ -737,6 +781,41 @@ onMounted(async () => {
|
||||
</table>
|
||||
</div>
|
||||
</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'">
|
||||
<p v-if="!cities.collected">선택한 시점의 표본이 없습니다.</p>
|
||||
<p v-else-if="!cities.items.length">조건에 맞는 도시가 없습니다.</p>
|
||||
|
||||
@@ -158,7 +158,7 @@ migration head를 가리킨다. 실제 PG의55→56/빈56/no-op·기존 값/null
|
||||
않고 조회 버튼으로 URL에 적용하며, 더 보기와 새로고침도 같은 필터·정렬을 유지한다.
|
||||
도시→모든 주둔 장수 연결에서는 이름 조건도 해제한다. 과거 검색은 sampleId로 먼저
|
||||
좁힌 뒤 당시 JSON 이름을 검사하며 현재 이름을 참조하지 않는다. 역순은 ID `< cursor`로
|
||||
페이지를 잇는다. 자원·능력별 정렬은 복합 cursor와 비용 검토가 추가로 필요하다.
|
||||
페이지를 잇는다. 자원·능력별 정렬은 아래 2026-09-26 보완에서 복합 cursor로 추가했다.
|
||||
실제 PG120개월×1,000명 fixture에서 선택 월 PK1000행, 국가 추가 시50행으로 후보를
|
||||
좁혔다. 이 한 fixture의 실행계획은 전체 COST gate나 운영 p95 증거를 대신하지 않는다.
|
||||
|
||||
@@ -185,8 +185,8 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. Chart.js로 국
|
||||
국가는 검색 가능한 목록에서 즉시 선택하며 장수·도시 클릭은 상세 영역에 focus를 옮긴다.
|
||||
390px 모바일에서 문서 가로 넘침 없음,
|
||||
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
|
||||
월말/FINAL 장수·도시 projection을 보여주지만 지도,
|
||||
전투 통계, 자원·능력별 정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
|
||||
월말/FINAL 장수·도시 projection과 도시 지도, 자원·능력별 정렬을 제공한다.
|
||||
전투 통계는 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
|
||||
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
|
||||
|
||||
Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체
|
||||
@@ -214,7 +214,7 @@ FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없
|
||||
넘은 잘못된 값도 숨기지 않는다. 인자는 읽기 전용 `argumentJson` 텍스트로 반환한다.
|
||||
범용 JSON의 재귀 타입을 UI에 그대로 전달하지 않으면서 값은 생략하지 않는다.
|
||||
예약 조회 실패는 장수 상세를 지우지 않는다. 과거 예약 변경은 이후 사건 원장이 담당하며
|
||||
현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 통계와 지도·검색/정렬은 남는다.
|
||||
현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 통계는 남는다. 지도·검색/정렬은 후속 보완으로 구현했다.
|
||||
|
||||
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
|
||||
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
|
||||
@@ -685,3 +685,24 @@ transaction의 `world_state.meta.playAuditFlows`를 읽는다. 국가·자원·
|
||||
[responsive](https://www.chartjs.org/docs/latest/configuration/responsive.html) 계약을 사용한다.
|
||||
필요한 line 구성요소만 등록하고 resize·unmount를 처리하며 null 구간을 연결하지 않는다.
|
||||
차트와 수치 표는 같은 응답을 사용하고 집단/지표 전환은 추가 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 완료로 해석하지 않는다.
|
||||
|
||||
Reference in New Issue
Block a user