플레이 감사에 장수 수치 정렬과 시점별 도시 지도를 추가한다
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({
|
||||
|
||||
Reference in New Issue
Block a user