merge: 최신 main을 첩보 명령 복원에 반영

This commit is contained in:
2026-08-20 16:08:06 +00:00
25 changed files with 2041 additions and 251 deletions
+13 -4
View File
@@ -4,11 +4,13 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { procedure, router } from '../../trpc.js';
import type { LegacyEmperorRow } from '../../services/legacyArchiveStore.js';
import {
findLegacyEmperor,
findLegacyEmperors,
findLegacyEmperorsByProfile,
findLegacyGeneralsForServer,
findLegacyNations,
isLegacyArchiveProfile,
} from '../../services/legacyArchiveStore.js';
const zDynastyDetailInput = z.object({
@@ -65,7 +67,7 @@ const firstText = (...values: unknown[]): string => {
return '';
};
const legacyEmperorListEntry = (row: Awaited<ReturnType<typeof findLegacyEmperors>>[number]) => {
const legacyEmperorListEntry = (row: LegacyEmperorRow) => {
const data = asRecord(row.data);
return {
id: Number(row.id),
@@ -132,7 +134,9 @@ const formatNationLevel = (level: number | null): string => {
export const dynastyRouter = router({
getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => {
if ((input?.source ?? 'current') === 'legacy') {
const rows = await findLegacyEmperors(ctx.db);
const rows = isLegacyArchiveProfile(ctx.profile.id)
? await findLegacyEmperorsByProfile(ctx.db, ctx.profile.id)
: [];
return {
source: 'legacy' as const,
current: null,
@@ -186,7 +190,12 @@ export const dynastyRouter = router({
}),
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
if (input.source === 'legacy') {
const archived = await findLegacyEmperor(ctx.db, input.emperorId);
const archived = isLegacyArchiveProfile(ctx.profile.id)
? await findLegacyEmperor(ctx.db, {
id: input.emperorId,
sourceProfile: ctx.profile.id,
})
: null;
if (!archived) {
throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' });
}
+5 -4
View File
@@ -13,6 +13,7 @@ import {
} from '@sammo-ts/logic';
import type { InheritBuffType } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import {
appendInheritanceLog,
buildResetCost,
@@ -74,17 +75,17 @@ const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): numb
};
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
const configConst = asRecord(asRecord(worldState.config).const);
const loader = new ItemLoader();
const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader);
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
for (const entries of Object.values(asRecord(configuredItems))) {
for (const entries of Object.values(allItems)) {
for (const [key, amount] of Object.entries(asRecord(entries))) {
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
enabledKeys.push(key);
}
}
}
const loader = new ItemLoader();
const items = await Promise.all(
[...new Set(enabledKeys)].map(async (key) => {
const item = await loader.load(key);
@@ -270,7 +270,26 @@ export const findLegacyEmperors = async (
`);
};
export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise<LegacyEmperorRow | null> => {
export const findLegacyEmperorsByProfile = async (
db: LegacyArchiveDatabase,
sourceProfile: LegacyArchiveProfile
): Promise<LegacyEmperorRow[]> =>
db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT
"id",
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"data"
FROM "legacy_archive"."emperor"
WHERE "source_profile" = ${sourceProfile}
ORDER BY "id" DESC
`);
export const findLegacyEmperor = async (
db: LegacyArchiveDatabase,
input: { id: number; sourceProfile: LegacyArchiveProfile }
): Promise<LegacyEmperorRow | null> => {
const rows = await db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT
"id",
@@ -279,7 +298,8 @@ export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number):
"server_id" AS "serverId",
"data"
FROM "legacy_archive"."emperor"
WHERE "id" = ${id}
WHERE "id" = ${input.id}
AND "source_profile" = ${input.sourceProfile}
LIMIT 1
`);
return rows[0] ?? null;
+43 -17
View File
@@ -120,20 +120,24 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload
const buildContext = (
auth: GameSessionTokenPayload | null,
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation]
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation],
profileId = profile.id
): GameApiContext => {
const selectedProfile = { ...profile, id: profileId, name: `${profileId}:default` };
const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => {
$queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => {
const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."emperor')) {
if (!query.values?.includes(selectedProfile.id)) return [];
if (sql.includes('WHERE "id"') && !query.values.includes(101)) return [];
return [
{
id: 101n,
sourceProfile: 'hwe',
sourceProfile: selectedProfile.id,
legacyId: 7,
serverId: emperor.serverId,
data: {
phase: '이전 훼2기',
phase: `이전 ${selectedProfile.id.toUpperCase()} 2기`,
nation_count: emperor.nationCount,
nation_name: emperor.nationName,
nation_hist: emperor.nationHist,
@@ -170,9 +174,10 @@ const buildContext = (
];
}
if (sql.includes('legacy_archive"."nation')) {
if (!query.values?.includes(selectedProfile.id)) return [];
return [
{
sourceProfile: 'hwe',
sourceProfile: selectedProfile.id,
legacyId: oldNation.id,
serverId: oldNation.serverId,
nation: oldNation.nation,
@@ -182,6 +187,7 @@ const buildContext = (
];
}
if (sql.includes('legacy_archive"."general')) {
if (!query.values?.includes(selectedProfile.id)) return [];
return [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
@@ -217,13 +223,13 @@ const buildContext = (
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
profile: selectedProfile,
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
accessTokenStore: new RedisAccessTokenStore(redis, selectedProfile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
@@ -252,27 +258,31 @@ describe('dynasty public read model', () => {
]);
});
it('reads previous-server dynasties only when the archive source is selected', async () => {
const caller = appRouter.createCaller(buildContext(null));
const list = await caller.dynasty.getList({ source: 'legacy' });
expect(list).toMatchObject({
it('scopes previous-server dynasties and detail to the request profile', async () => {
const cheCaller = appRouter.createCaller(buildContext(null));
const cheList = await cheCaller.dynasty.getList({ source: 'legacy' });
expect(cheList).toMatchObject({
source: 'legacy',
current: null,
entries: [
expect.objectContaining({
id: 101,
source: 'legacy',
sourceProfile: 'hwe',
phase: '이전 2기',
sourceProfile: 'che',
phase: '이전 CHE 2기',
}),
],
});
const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
expect(detail).toMatchObject({
const staleListInput = { source: 'legacy' as const, sourceProfile: 'hwe' as const };
const staleList = await cheCaller.dynasty.getList(staleListInput);
expect(staleList.entries.map((entry) => entry.sourceProfile)).toEqual(['che']);
const cheDetail = await cheCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
expect(cheDetail).toMatchObject({
source: 'legacy',
sourceProfile: 'hwe',
emperor: expect.objectContaining({ id: 101, phase: '이전 2기', name: '촉' }),
sourceProfile: 'che',
emperor: expect.objectContaining({ id: 101, phase: '이전 CHE 2기', name: '촉' }),
nations: [
expect.objectContaining({
name: '촉',
@@ -283,6 +293,22 @@ describe('dynasty public read model', () => {
}),
],
});
const staleDetailInput = { emperorId: 101, source: 'legacy' as const, sourceProfile: 'hwe' as const };
const staleDetail = await cheCaller.dynasty.getDetail(staleDetailInput);
expect(staleDetail.sourceProfile).toBe('che');
const hweCaller = appRouter.createCaller(buildContext(null, undefined, 'hwe'));
const hweList = await hweCaller.dynasty.getList({ source: 'legacy' });
expect(hweList.entries).toEqual([expect.objectContaining({ sourceProfile: 'hwe', phase: '이전 HWE 2기' })]);
const hweDetail = await hweCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
expect(hweDetail.sourceProfile).toBe('hwe');
const developmentCaller = appRouter.createCaller(buildContext(null, undefined, 'development'));
await expect(developmentCaller.dynasty.getList({ source: 'legacy' })).resolves.toMatchObject({ entries: [] });
await expect(developmentCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
it('exposes the same public DTO to anonymous, general owners and admins', async () => {
+27 -1
View File
@@ -97,6 +97,7 @@ const buildContext = (options: {
target?: GeneralRow | null;
inheritancePoint?: number;
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
configConst?: Record<string, unknown>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -113,10 +114,19 @@ const buildContext = (options: {
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
const activeWorldState =
options.configConst === undefined
? worldState
: {
...worldState,
config: {
const: options.configConst,
},
};
const db = {
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
worldState: {
findFirst: vi.fn(async () => worldState),
findFirst: vi.fn(async () => activeWorldState),
},
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
@@ -192,6 +202,22 @@ describe('inherit router actor and permission boundaries', () => {
});
});
it.each([{}, { allItems: '{}' }])(
'restores selectable Ref default uniques for a legacy scenario config: %j',
async (configConst) => {
const fixture = buildContext({ configConst });
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
expect(status.availableUnique.length).toBeGreaterThan(80);
expect(status.availableUnique).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
expect.objectContaining({ key: 'che_서적_07_논어', rawName: '논어' }),
])
);
}
);
it('loads the first inheritance-log page without an out-of-range integer cursor', async () => {
const createdAt = new Date('2026-07-26T00:00:00Z');
const fixture = buildContext({