Merge branch 'main' into feature/general-icon-names

This commit is contained in:
2026-07-26 06:10:55 +00:00
12 changed files with 1555 additions and 325 deletions
+68 -13
View File
@@ -2,22 +2,62 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { router, procedure } from '../../trpc.js';
import { procedure, router } from '../../trpc.js';
const zDynastyDetailInput = z.object({
emperorId: z.number().int().positive(),
});
const parseNumberArray = (value: unknown): number[] =>
Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : [];
Array.isArray(value)
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
: [];
const parseTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
const parseDisplayArray = (value: unknown): Array<string | number> =>
Array.isArray(value)
? value.filter(
(item): item is string | number =>
typeof item === 'string' || (typeof item === 'number' && Number.isFinite(item))
)
: [];
const formatNationType = (typeCode: string): string => {
const separator = typeCode.indexOf('_');
return separator < 0 ? typeCode : typeCode.slice(separator + 1);
};
const formatNationLevel = (level: number | null): string => {
if (level === null) {
return '';
}
return ['방랑군', '호족', '군벌', '주자사', '주목', '공', '왕', '황제'][level] ?? String(level);
};
export const dynastyRouter = router({
getList: procedure.query(async ({ ctx }) => {
const rows = await ctx.db.emperor.findMany({
orderBy: { id: 'desc' },
});
const [worldState, rows] = await Promise.all([
ctx.db.worldState.findFirst({
select: {
currentYear: true,
currentMonth: true,
},
}),
ctx.db.emperor.findMany({
orderBy: { id: 'desc' },
}),
]);
return {
current: worldState
? {
year: worldState.currentYear,
month: worldState.currentMonth,
}
: null,
entries: rows.map((row) => ({
id: row.id,
serverId: row.serverId ?? '',
@@ -30,11 +70,19 @@ export const dynastyRouter = router({
power: row.power ?? 0,
gennum: row.gennum ?? 0,
citynum: row.citynum ?? 0,
l12name: row.l12name ?? '',
l11name: row.l11name ?? '',
l10name: row.l10name ?? '',
l9name: row.l9name ?? '',
l8name: row.l8name ?? '',
l7name: row.l7name ?? '',
l6name: row.l6name ?? '',
l5name: row.l5name ?? '',
})),
};
}),
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
const emperor = await ctx.db.emperor.findFirst({
const emperor = await ctx.db.emperor.findUnique({
where: { id: input.emperorId },
});
if (!emperor) {
@@ -43,7 +91,6 @@ export const dynastyRouter = router({
const aux = asRecord(emperor.aux);
const winnerNationId = typeof aux.winnerNationId === 'number' ? aux.winnerNationId : null;
const serverId = emperor.serverId ?? '';
const oldNationRows = await ctx.db.oldNation.findMany({
where: { serverId },
@@ -54,19 +101,26 @@ export const dynastyRouter = router({
.map((row) => {
const data = asRecord(row.data);
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
const typeCode = typeof data.type === 'string' ? data.type : '';
return {
nation: nationId,
isWinner: winnerNationId !== null && nationId === winnerNationId,
name: typeof data.name === 'string' ? data.name : row.nation === 0 ? '재야' : '미상',
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
color: typeof data.color === 'string' ? data.color : '#000000',
type: typeof data.type === 'string' ? data.type : '',
type: typeCode,
typeName: formatNationType(typeCode),
level: typeof data.level === 'number' ? data.level : null,
tech: typeof data.tech === 'number' ? data.tech : null,
maxPower: typeof data.maxPower === 'number' ? data.maxPower : null,
maxPower:
typeof data.maxPower === 'number'
? data.maxPower
: typeof data.power === 'number'
? data.power
: null,
maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null,
maxCities: Array.isArray(data.maxCities) ? data.maxCities : [],
maxCities: parseDisplayArray(data.maxCities),
generals: parseNumberArray(data.generals),
history: Array.isArray(data.history) ? data.history : [],
history: parseTextArray(data.history),
date: row.date.toISOString(),
};
})
@@ -89,6 +143,7 @@ export const dynastyRouter = router({
const nations = nationEntries.map((entry) => ({
...entry,
levelName: formatNationLevel(entry.level),
generalsFull: entry.generals.map((id) => ({
generalNo: id,
name: generalMap.get(id)?.name ?? `#${id}`,
@@ -131,7 +186,7 @@ export const dynastyRouter = router({
tiger: emperor.tiger ?? '',
eagle: emperor.eagle ?? '',
gen: emperor.gen ?? '',
history: Array.isArray(emperor.history) ? emperor.history : [],
history: parseTextArray(emperor.history),
},
nations,
};
+65 -30
View File
@@ -7,7 +7,8 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import { procedure, router } from '../../trpc.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
type YearbookNation = {
id: number;
@@ -19,6 +20,7 @@ type YearbookNation = {
};
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const zServerId = z.string().trim().min(1).max(64);
const computeHash = (payload: unknown): string =>
createHash('sha256').update(JSON.stringify(payload)).digest('hex');
@@ -189,53 +191,86 @@ const buildLogs = async (ctx: GameApiContext, year: number, month: number) => {
};
export const yearbookRouter = router({
getRange: procedure.query(async ({ ctx }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
getRange: authedProcedure
.input(
z
.object({
serverID: zServerId.optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const targetProfileName = input?.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const firstRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: ctx.profile.name },
select: { year: true, month: true },
orderBy: [{ year: 'asc' }, { month: 'asc' }],
});
const firstRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: targetProfileName },
select: { year: true, month: true },
orderBy: [{ year: 'asc' }, { month: 'asc' }],
});
const lastRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: ctx.profile.name },
select: { year: true, month: true },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
});
const lastRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: targetProfileName },
select: { year: true, month: true },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
});
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
const fallbackYearMonth = currentYearMonth - 1;
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
if (!isCurrentProfile && (!firstRow || !lastRow)) {
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
}
return {
firstYearMonth,
lastYearMonth,
currentYearMonth,
};
}),
getHistory: procedure
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
const fallbackYearMonth = currentYearMonth - 1;
const firstYearMonth = firstRow
? joinYearMonth(firstRow.year, firstRow.month)
: fallbackYearMonth;
const lastYearMonth = lastRow
? joinYearMonth(lastRow.year, lastRow.month)
: fallbackYearMonth;
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
return {
firstYearMonth,
lastYearMonth,
currentYearMonth: selectedYearMonth,
};
}),
getHistory: authedProcedure
.input(
z.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
hash: z.string().optional(),
serverID: zServerId.optional(),
})
)
.query(async ({ ctx, input }) => {
await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const targetProfileName = input.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const isCurrent =
isCurrentProfile &&
worldState.currentYear === input.year && worldState.currentMonth === input.month;
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
const { globalHistory, globalAction } = isCurrentProfile
? await buildLogs(ctx, input.year, input.month)
: {
globalHistory: [`<C>●</>${input.month}월: 기록 없음`],
globalAction: [`<C>●</>${input.month}월: 기록 없음`],
};
if (isCurrent) {
const map = await loadPublicMap(ctx, false);
@@ -260,7 +295,7 @@ export const yearbookRouter = router({
const row = await ctx.db.yearbookHistory.findFirst({
where: {
profileName: ctx.profile.name,
profileName: targetProfileName,
year: input.year,
month: input.month,
},
@@ -285,4 +320,4 @@ export const yearbookRouter = router({
}
return { notModified: false, hash, data };
}),
});
});