merge: 최신 main을 첩보 명령 복원에 반영
This commit is contained in:
@@ -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: '이전 서버 왕조 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user