fix(api): align scenario 2601 reference data

This commit is contained in:
2026-08-04 05:29:12 +00:00
parent 87965a39d6
commit 9cfeaed3fe
10 changed files with 212 additions and 78 deletions
+4
View File
@@ -1,5 +1,7 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import { procedure, router } from '../../trpc.js';
@@ -23,6 +25,7 @@ export const lobbyRouter = router({
const userCnt = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
let myGeneral = null;
if (ctx.auth?.user.id) {
@@ -55,6 +58,7 @@ export const lobbyRouter = router({
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
npcPossessionEnabled: worldState.config.npcMode === 1,
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
myGeneral,
};
}),
@@ -27,6 +27,8 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
select: {
id: true,
name: true,
picture: true,
imageServer: true,
npcState: true,
officerLevel: true,
cityId: true,
@@ -43,6 +45,16 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
crew: true,
train: true,
atmos: true,
age: true,
crewTypeId: true,
weaponCode: true,
bookCode: true,
horseCode: true,
itemCode: true,
personalCode: true,
specialCode: true,
special2Code: true,
meta: true,
},
orderBy: { id: 'asc' },
}),
@@ -79,29 +91,62 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
}
}
const generals = generalRows.map((general) => ({
id: general.id,
name: general.name,
npcState: general.npcState,
officerLevel: general.officerLevel,
cityId: general.cityId,
turnTime: formatDateTime(general.turnTime),
recentWar: formatDateTime(general.recentWarTime),
warnum: battleCountMap.get(general.id) ?? 0,
stats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
experience: general.experience,
dedication: general.dedication,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
train: general.train,
atmos: general.atmos,
}));
const generals = generalRows.map((general) => {
const meta =
general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta)
? (general.meta as Record<string, unknown>)
: {};
const metaNumber = (key: string): number => {
const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
};
return {
id: general.id,
name: general.name,
picture: general.picture,
imageServer: general.imageServer,
npcState: general.npcState,
officerLevel: general.officerLevel,
cityId: general.cityId,
turnTime: formatDateTime(general.turnTime),
recentWar: formatDateTime(general.recentWarTime),
warnum: battleCountMap.get(general.id) ?? 0,
stats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
experience: general.experience,
dedication: general.dedication,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
train: general.train,
atmos: general.atmos,
age: general.age,
crewTypeId: general.crewTypeId,
equipment: {
weapon: general.weaponCode,
book: general.bookCode,
horse: general.horseCode,
item: general.itemCode,
},
traits: {
personal: general.personalCode,
specialDomestic: general.specialCode,
specialWar: general.special2Code,
},
battleStats: {
kills: metaNumber('rank_killnum') || metaNumber('killnum'),
deaths: metaNumber('deathnum'),
fire: metaNumber('firenum'),
killCrew: metaNumber('killcrew'),
deathCrew: metaNumber('deathcrew'),
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
},
};
});
return {
me: {
@@ -5,7 +5,13 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission, zGeneralLogType, type GeneralLogType } from '../shared.js';
import {
assertNationAccess,
formatDateTime,
resolveNationPermission,
zGeneralLogType,
type GeneralLogType,
} from '../shared.js';
export const getGeneralLog = authedProcedure
.input(
@@ -75,6 +81,9 @@ export const getGeneralLog = authedProcedure
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
});
@@ -30,7 +30,7 @@ export const getNationInfo = authedProcedure.query(async ({ ctx }) => {
nationId: me.nationId,
},
select: { id: true, year: true, month: true, text: true },
orderBy: { id: 'asc' },
orderBy: { id: 'desc' },
}),
]);
if (!nation) {
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { loadUnitSetDefinitionByName } from '../../../battleSim/unitSetLoader.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../shared.js';
@@ -41,7 +42,7 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
});
}
const [cities, troops, generalRows] = await Promise.all([
const [cities, troops, generalRows, worldState] = await Promise.all([
ctx.db.city.findMany({ select: { id: true, name: true } }),
ctx.db.troop.findMany({
where: { nationId: me.nationId },
@@ -51,7 +52,14 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
where: { nationId: me.nationId },
orderBy: [{ turnTime: 'asc' }, { id: 'asc' }],
}),
ctx.db.worldState.findFirst({ select: { config: true } }),
]);
const worldConfig = asRecord(worldState?.config);
const environment = asRecord(worldConfig.environment ?? worldConfig.map);
const unitSetName =
typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : ctx.profile.id;
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const crewTypeNames = new Map((unitSet.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]));
const generalIds = generalRows.map((general) => general.id);
const turns = generalIds.length
? await ctx.db.generalTurn.findMany({
@@ -92,6 +100,7 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
defenceTrain,
defenceTrainText: defenceTrainText(defenceTrain),
crewTypeId: general.crewTypeId,
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
crew: general.crew,
train: general.train,
atmos: general.atmos,
@@ -1,7 +1,14 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome, type NationIncomeContext } from '@sammo-ts/logic';
import {
getGoldIncome,
getOutcome,
getRiceIncome,
getWallIncome,
getWarGoldIncome,
type NationIncomeContext,
} from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
@@ -171,13 +178,7 @@ export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => {
const cityStatsByNation = new Map<number, { popSum: number; valueSum: number; maxSum: number }>();
for (const city of cityRows) {
const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const valueSum =
city.population +
city.agriculture +
city.commerce +
city.security +
city.wall +
city.defence;
const valueSum = city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
const maxSum =
city.populationMax +
city.agricultureMax +
@@ -222,22 +223,24 @@ export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => {
);
}
const nationsList = nationRows.map((nationItem) => {
const diplomacy =
nationItem.id === nation.id
? { state: 7, term: null }
: diplomacyMap.get(nationItem.id) ?? { state: 2, term: 0 };
return {
id: nationItem.id,
name: nationItem.name,
color: nationItem.color,
level: nationItem.level,
power: powerByNation.get(nationItem.id) ?? 0,
generalCount: generalCountMap.get(nationItem.id) ?? 0,
cityCount: cityCountMap.get(nationItem.id) ?? 0,
diplomacy,
};
});
const nationsList = nationRows
.filter((nationItem) => nationItem.id > 0)
.map((nationItem) => {
const diplomacy =
nationItem.id === nation.id
? { state: 7, term: null }
: (diplomacyMap.get(nationItem.id) ?? { state: 2, term: 0 });
return {
id: nationItem.id,
name: nationItem.name,
color: nationItem.color,
level: nationItem.level,
power: powerByNation.get(nationItem.id) ?? 0,
generalCount: generalCountMap.get(nationItem.id) ?? 0,
cityCount: cityCountMap.get(nationItem.id) ?? 0,
diplomacy,
};
});
const nationCities = cityRows.filter((city) => city.nationId === nation.id);
const nationGenerals = generalRows.filter((general) => general.nationId === nation.id);
+6 -7
View File
@@ -143,9 +143,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
const nationGenerals = generalsByNation.get(nation.id) ?? [];
const nationCities = citiesByNation.get(nation.id) ?? [];
const officers = Array.from({ length: 8 }, (_, index) => 12 - index).map((officerLevel) => {
const general = nationGenerals
.filter((candidate) => candidate.officerLevel === officerLevel)
.at(-1);
const general = nationGenerals.filter((candidate) => candidate.officerLevel === officerLevel).at(-1);
return {
officerLevel,
general: general
@@ -178,7 +176,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
},
power: readMetaNumber(nation.meta, 'power'),
capitalCityId: nation.capitalCityId ?? 0,
generalCount: nationGenerals.length,
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
cityCount: nationCities.length,
officers,
ambassadorNames: secretPermissions
@@ -204,8 +202,8 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
});
});
export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional())
.query(async ({ ctx, input }) => {
export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional()).query(
async ({ ctx, input }) => {
await getMyGeneral(ctx);
const sort = input?.sort ?? 9;
const [generals, nations, accessLogs, worldState] = await Promise.all([
@@ -365,4 +363,5 @@ export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: z
});
return { sort, generals: rows };
});
}
);
+23 -11
View File
@@ -7,10 +7,7 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import {
generalAccessEndpointWeights,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { generalAccessEndpointWeights, recordGeneralAccessWeight } from '../../services/generalAccess.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -72,6 +69,7 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => {
const resolveArchiveTarget = (
worldMeta: unknown,
profileId: string,
profileName: string,
requestedServerId?: string
): { archiveKey: string; legacyAlias: string | null; isCurrentProfile: boolean } => {
@@ -81,7 +79,12 @@ const resolveArchiveTarget = (
const isCurrentProfile = requested === profileName || requested === canonicalServerId;
return {
archiveKey: isCurrentProfile ? canonicalServerId : requested,
legacyAlias: isCurrentProfile && canonicalServerId !== profileName ? profileName : null,
legacyAlias:
isCurrentProfile && canonicalServerId !== profileName
? profileName
: isCurrentProfile && profileId !== canonicalServerId
? profileId
: null,
isCurrentProfile,
};
};
@@ -263,7 +266,7 @@ export const yearbookRouter = router({
message: 'World state is not initialized.',
});
}
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input?.serverID);
const target = resolveArchiveTarget(worldState.meta, ctx.profile.id, ctx.profile.name, input?.serverID);
const findRange = async (profileName: string) =>
Promise.all([
@@ -278,10 +281,19 @@ export const yearbookRouter = router({
orderBy: [{ year: 'desc' as const }, { month: 'desc' as const }],
}),
]);
let [firstRow, lastRow] = await findRange(target.archiveKey);
if ((!firstRow || !lastRow) && target.legacyAlias) {
[firstRow, lastRow] = await findRange(target.legacyAlias);
}
const ranges = await Promise.all(
[target.archiveKey, target.legacyAlias]
.filter((value): value is string => Boolean(value))
.map(findRange)
);
const firstRow = ranges
.map(([first]) => first)
.filter((row): row is NonNullable<typeof row> => Boolean(row))
.sort((a, b) => joinYearMonth(a.year, a.month) - joinYearMonth(b.year, b.month))[0];
const lastRow = ranges
.map(([, last]) => last)
.filter((row): row is NonNullable<typeof row> => Boolean(row))
.sort((a, b) => joinYearMonth(b.year, b.month) - joinYearMonth(a.year, a.month))[0];
if (!target.isCurrentProfile && (!firstRow || !lastRow)) {
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
@@ -314,7 +326,7 @@ export const yearbookRouter = router({
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input.serverID);
const target = resolveArchiveTarget(worldState.meta, ctx.profile.id, ctx.profile.name, input.serverID);
const shouldRecordAfterHashCheck = target.isCurrentProfile && Boolean(input.hash);
if (target.isCurrentProfile && !shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
@@ -79,7 +79,7 @@ const createContext = (options: {
nationMeta?: Record<string, unknown>;
requestCommand?: ReturnType<typeof vi.fn>;
accessToken?: string;
logs?: Array<{ id: number; text: string }>;
logs?: Array<{ id: number; text: string; year?: number; month?: number; createdAt?: Date }>;
}) => {
const me = options.me === undefined ? buildGeneral() : options.me;
const targets = options.targets ?? (me ? [me] : []);
@@ -119,12 +119,24 @@ const createContext = (options: {
},
logEntry: {
groupBy: vi.fn(async () => []),
findMany: vi.fn(async (query?: { where?: { id?: { lt?: number } }; take?: number }) => {
const source = options.logs ?? [{ id: 1, text: '기록' }];
const beforeId = query?.where?.id?.lt;
const filtered = beforeId ? source.filter((entry) => entry.id < beforeId) : source;
return query?.take ? filtered.slice(0, query.take) : filtered;
}),
findMany: vi.fn(
async (query?: {
where?: { id?: { lt?: number } };
take?: number;
select?: { id?: boolean; text?: boolean };
}) => {
const source = (options.logs ?? [{ id: 1, text: '기록' }]).map((entry) => ({
year: 185,
month: 1,
createdAt: now,
...entry,
}));
const beforeId = query?.where?.id?.lt;
const filtered = beforeId ? source.filter((entry) => entry.id < beforeId) : source;
const selected = query?.select ? filtered.map(({ id, text }) => ({ id, text })) : filtered;
return query?.take ? selected.slice(0, query.take) : selected;
}
),
},
};
const redisClient = { get: async () => null, set: async () => null };
@@ -405,6 +417,14 @@ describe('battle-center general and user permissions', () => {
});
await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({
me: { id: 7, permissionLevel: 1 },
generals: [
{
id: 7,
picture: 'default.jpg',
imageServer: 0,
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [0, 0, 0, 0, 0] },
},
],
});
const auditor = createContext({
@@ -430,6 +450,7 @@ describe('battle-center general and user permissions', () => {
await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({
generalId: me.id,
logs: [{ id: 1, year: 185, month: 1, createdAt: '2026-01-01 00:00:00' }],
});
await expect(
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
@@ -52,12 +52,27 @@ const archiveRows = [
year: 219,
month: 12,
map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, generalCount: 10, cities: ['낙양'] }],
nations: [
{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, generalCount: 10, cities: ['낙양'] },
],
globalHistory: ['저장된 현재 기수 과거 기록'],
globalAction: ['저장된 현재 기수 과거 행동'],
hash: 'current-archive',
createdAt: new Date('2026-07-31T00:00:00.000Z'),
},
{
id: 4,
profileName: profile.id,
sourceId: 201,
year: 219,
month: 11,
map: { year: 219, month: 11, startYear: 190, cityList: [], nationList: [] },
nations: [],
globalHistory: ['레거시 프로필 별칭 기록'],
globalAction: [],
hash: 'legacy-profile-alias',
createdAt: new Date('2026-07-30T00:00:00.000Z'),
},
];
const authFor = (userId: string): GameSessionTokenPayload => ({
@@ -75,14 +90,21 @@ const authFor = (userId: string): GameSessionTokenPayload => ({
sanctions: {},
});
const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGeneral?: boolean } = {}): GameApiContext => {
const buildContext = (
auth: GameSessionTokenPayload | null,
options: { hasGeneral?: boolean; worldMeta?: unknown } = {}
): GameApiContext => {
const db = {
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
},
worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1, meta: { serverId: currentServerId } }),
findFirst: async () => ({
currentYear: 220,
currentMonth: 1,
meta: options.worldMeta ?? { serverId: currentServerId },
}),
},
yearbookHistory: {
findFirst: async (args: {
@@ -198,6 +220,16 @@ describe('historical yearbook access from dynasty', () => {
});
});
it('reads imported history under the short profile ID when world metadata has no server ID', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a'), { worldMeta: {} }));
await expect(caller.yearbook.getRange()).resolves.toEqual({
firstYearMonth: 219 * 12 + 10,
lastYearMonth: 219 * 12 + 10,
currentYearMonth: 220 * 12,
});
});
it('uses stored logs for a past month of the current generation', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));