Merge branch 'main' into feature/general-icon-names
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const profile: GameProfile = {
|
||||
id: 'che',
|
||||
scenario: 'default',
|
||||
name: 'che:default',
|
||||
};
|
||||
|
||||
const emperor = {
|
||||
id: 7,
|
||||
serverId: 'hwe_260725_fixture',
|
||||
phase: '훼2기',
|
||||
nationCount: '3 / 8',
|
||||
nationName: '촉, 위, 오',
|
||||
nationHist: '병가(2), 유가(1)',
|
||||
genCount: '7 / 21',
|
||||
personalHist: '의리(4)',
|
||||
specialHist: '상재(2)',
|
||||
name: '촉',
|
||||
type: 'che_병가',
|
||||
color: '#FF0000',
|
||||
year: 215,
|
||||
month: 4,
|
||||
power: 34434,
|
||||
gennum: 7,
|
||||
citynum: 8,
|
||||
pop: '12345 / 15000',
|
||||
poprate: '82.3 %',
|
||||
gold: 50000,
|
||||
rice: 60000,
|
||||
l12name: '유비',
|
||||
l12pic: '',
|
||||
l11name: '제갈량',
|
||||
l11pic: '',
|
||||
l10name: '관우',
|
||||
l10pic: '',
|
||||
l9name: '방통',
|
||||
l9pic: '',
|
||||
l8name: '장비',
|
||||
l8pic: '',
|
||||
l7name: '법정',
|
||||
l7pic: '',
|
||||
l6name: '조운',
|
||||
l6pic: '',
|
||||
l5name: '마량',
|
||||
l5pic: '',
|
||||
tiger: '관우【10】',
|
||||
eagle: '방통【7】',
|
||||
gen: '유비, 제갈량',
|
||||
history: ['<C>●</>촉이 천하를 통일'],
|
||||
aux: { winnerNationId: 1, privateNote: 'not-returned' },
|
||||
};
|
||||
|
||||
const oldNation = {
|
||||
id: 3,
|
||||
serverId: emperor.serverId,
|
||||
nation: 1,
|
||||
data: {
|
||||
nation: 1,
|
||||
name: '촉',
|
||||
color: '#FF0000',
|
||||
type: 'che_병가',
|
||||
level: 7,
|
||||
tech: 4000,
|
||||
power: 34434,
|
||||
maxCrew: 120000,
|
||||
maxCities: ['성도', '한중'],
|
||||
generals: [11, 12],
|
||||
history: ['<Y>유비</>가 황제로 즉위'],
|
||||
owner: 'not-returned',
|
||||
},
|
||||
date: new Date('2026-07-25T12:00:00.000Z'),
|
||||
};
|
||||
|
||||
const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: '2026-07-25T00:00:00.000Z',
|
||||
expiresAt: '2026-07-26T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => {
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||
},
|
||||
emperor: {
|
||||
findMany: async () => [emperor],
|
||||
findUnique: async ({ where }: { where: { id: number } }) => (where.id === emperor.id ? emperor : null),
|
||||
},
|
||||
oldNation: {
|
||||
findMany: async ({ where }: { where: { serverId: string } }) =>
|
||||
where.serverId === emperor.serverId ? [oldNation] : [],
|
||||
},
|
||||
oldGeneral: {
|
||||
findMany: async () => [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
} as unknown as RedisConnector['client'];
|
||||
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile,
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('dynasty public read model', () => {
|
||||
it('returns the current row and the complete legacy officer summary', async () => {
|
||||
const result = await appRouter.createCaller(buildContext(null)).dynasty.getList();
|
||||
|
||||
expect(result.current).toEqual({ year: 220, month: 1 });
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
serverId: 'hwe_260725_fixture',
|
||||
phase: '훼2기',
|
||||
name: '촉',
|
||||
l12name: '유비',
|
||||
l11name: '제갈량',
|
||||
l10name: '관우',
|
||||
l9name: '방통',
|
||||
l8name: '장비',
|
||||
l7name: '법정',
|
||||
l6name: '조운',
|
||||
l5name: '마량',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the same public DTO to anonymous, general owners and admins', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const otherOwner = appRouter.createCaller(buildContext(authFor('owner-b')));
|
||||
const admin = appRouter.createCaller(buildContext(authFor('admin', ['admin'])));
|
||||
|
||||
const [anonymousList, ownerList, otherOwnerList, adminList] = await Promise.all([
|
||||
anonymous.dynasty.getList(),
|
||||
owner.dynasty.getList(),
|
||||
otherOwner.dynasty.getList(),
|
||||
admin.dynasty.getList(),
|
||||
]);
|
||||
expect(ownerList).toEqual(anonymousList);
|
||||
expect(otherOwnerList).toEqual(anonymousList);
|
||||
expect(adminList).toEqual(anonymousList);
|
||||
|
||||
const [anonymousDetail, ownerDetail, otherOwnerDetail, adminDetail] = await Promise.all([
|
||||
anonymous.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
owner.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
otherOwner.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
admin.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
]);
|
||||
expect(ownerDetail).toEqual(anonymousDetail);
|
||||
expect(otherOwnerDetail).toEqual(anonymousDetail);
|
||||
expect(adminDetail).toEqual(anonymousDetail);
|
||||
});
|
||||
|
||||
it('returns only the legacy public archive fields and resolves general names', async () => {
|
||||
const result = await appRouter.createCaller(buildContext(authFor('owner-a'))).dynasty.getDetail({
|
||||
emperorId: emperor.id,
|
||||
});
|
||||
|
||||
expect(result.emperor).toEqual(
|
||||
expect.objectContaining({
|
||||
personalHist: '의리(4)',
|
||||
specialHist: '상재(2)',
|
||||
tiger: '관우【10】',
|
||||
eagle: '방통【7】',
|
||||
history: ['<C>●</>촉이 천하를 통일'],
|
||||
})
|
||||
);
|
||||
expect(result.nations).toEqual([
|
||||
expect.objectContaining({
|
||||
name: '촉',
|
||||
type: 'che_병가',
|
||||
typeName: '병가',
|
||||
levelName: '황제',
|
||||
maxPower: 34434,
|
||||
generalsFull: [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('privateNote');
|
||||
expect(JSON.stringify(result)).not.toContain('not-returned');
|
||||
expect(JSON.stringify(result)).not.toContain('"owner"');
|
||||
expect(JSON.stringify(result)).not.toContain('"data"');
|
||||
});
|
||||
|
||||
it('rejects invalid and missing record identifiers without querying another scope', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(null));
|
||||
|
||||
await expect(caller.dynasty.getDetail({ emperorId: 0 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(caller.dynasty.getDetail({ emperorId: 999 })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const profile: GameProfile = {
|
||||
id: 'che',
|
||||
scenario: 'default',
|
||||
name: 'che:default',
|
||||
};
|
||||
|
||||
const archiveServerId = 'hwe_260725_archive';
|
||||
const archiveRows = [
|
||||
{
|
||||
id: 1,
|
||||
profileName: archiveServerId,
|
||||
year: 200,
|
||||
month: 1,
|
||||
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
|
||||
hash: 'archive-1',
|
||||
createdAt: new Date('2026-07-25T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
profileName: archiveServerId,
|
||||
year: 200,
|
||||
month: 2,
|
||||
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
|
||||
hash: 'archive-2',
|
||||
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
|
||||
const authFor = (userId: string): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: '2026-07-25T00:00:00.000Z',
|
||||
expiresAt: '2026-07-26T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (
|
||||
auth: GameSessionTokenPayload | null,
|
||||
options: { hasGeneral?: boolean } = {}
|
||||
): 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 }),
|
||||
},
|
||||
yearbookHistory: {
|
||||
findFirst: async (args: {
|
||||
where: { profileName: string; year?: number; month?: number };
|
||||
orderBy?: Array<{ year?: 'asc' | 'desc'; month?: 'asc' | 'desc' }>;
|
||||
}) => {
|
||||
const candidates = archiveRows.filter(
|
||||
(row) =>
|
||||
row.profileName === args.where.profileName &&
|
||||
(args.where.year === undefined || row.year === args.where.year) &&
|
||||
(args.where.month === undefined || row.month === args.where.month)
|
||||
);
|
||||
if (!args.orderBy) {
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
const descending = args.orderBy[0]?.year === 'desc';
|
||||
return (descending ? candidates.at(-1) : candidates[0]) ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
} as unknown as RedisConnector['client'];
|
||||
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile,
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('historical yearbook access from dynasty', () => {
|
||||
it('selects the archived server range without treating it as the live month', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
|
||||
|
||||
expect(result).toEqual({
|
||||
firstYearMonth: 200 * 12,
|
||||
lastYearMonth: 200 * 12 + 1,
|
||||
currentYearMonth: 200 * 12 + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the same archived public history for distinct general owners', async () => {
|
||||
const ownerA = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const ownerB = appRouter.createCaller(buildContext(authFor('owner-b')));
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
ownerA.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
||||
ownerB.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
||||
]);
|
||||
|
||||
expect(resultB).toEqual(resultA);
|
||||
expect(resultA).toMatchObject({
|
||||
notModified: false,
|
||||
data: {
|
||||
year: 200,
|
||||
month: 2,
|
||||
nations: [{ id: 1, name: '촉', cities: ['성도'] }],
|
||||
globalHistory: ['<C>●</>2월: 기록 없음'],
|
||||
globalAction: ['<C>●</>2월: 기록 없음'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('requires both an authenticated user and a user-owned general like legacy v_history.php', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const noGeneral = appRouter.createCaller(buildContext(authFor('owner-a'), { hasGeneral: false }));
|
||||
|
||||
await expect(anonymous.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
await expect(noGeneral.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown archived server IDs instead of falling back to the live profile', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
|
||||
await expect(caller.yearbook.getRange({ serverID: 'missing-server' })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
message: '연감 범위를 찾을 수 없습니다.',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user