Merge branch 'main' into feature/command-argument-ui

This commit is contained in:
2026-07-26 09:07:46 +00:00
24 changed files with 1830 additions and 59 deletions
+62
View File
@@ -15,6 +15,18 @@ const zGeneralSettings = z.object({
});
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
const MAIN_RECORD_LIMIT = 15;
const trimRecentRecords = <Entry extends { id: number }>(entries: Entry[], cursor: number): Entry[] => {
if (entries.length === 0) {
return entries;
}
const result = [...entries];
if (result.at(-1)?.id === cursor || result.length > MAIN_RECORD_LIMIT) {
result.pop();
}
return result;
};
const readNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -319,4 +331,54 @@ export const generalRouter = router({
})),
};
}),
getRecentRecords: authedProcedure
.input(
z.object({
lastGeneralRecordId: z.number().int().nonnegative().default(0),
lastWorldHistoryId: z.number().int().nonnegative().default(0),
})
)
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const take = MAIN_RECORD_LIMIT + 1;
const [global, general, history] = await Promise.all([
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
id: { gte: input.lastGeneralRecordId },
},
orderBy: { id: 'desc' },
take,
select: { id: true, text: true },
}),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: me.id,
id: { gte: input.lastGeneralRecordId },
},
orderBy: { id: 'desc' },
take,
select: { id: true, text: true },
}),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
id: { gte: input.lastWorldHistoryId },
},
orderBy: { id: 'desc' },
take,
select: { id: true, text: true },
}),
]);
return {
global: trimRecentRecords(global, input.lastGeneralRecordId),
general: trimRecentRecords(general, input.lastGeneralRecordId),
history: trimRecentRecords(history, input.lastWorldHistoryId),
};
}),
});
+34 -2
View File
@@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import { z } from 'zod';
import type { GameApiContext } from '../../context.js';
@@ -241,14 +242,45 @@ export const publicRouter = router({
return loadMapLayout(ctx.profile.scenario);
}),
getCachedMap: procedure.query(async ({ ctx }) => {
const map = await loadPublicMap(ctx, true);
const cacheKey = buildPublicCacheKey(ctx, 'cachedMapWithHistory');
const cached = await ctx.redis.get(cacheKey);
if (cached) {
try {
return JSON.parse(cached) as NonNullable<Awaited<ReturnType<typeof loadPublicMap>>> & {
history: { id: number; text: string }[];
};
} catch {
// Ignore cache parse errors.
}
}
const [map, history] = await Promise.all([
loadPublicMap(ctx, true),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
},
select: {
id: true,
text: true,
},
orderBy: { id: 'desc' },
take: 10,
}),
]);
if (!map) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return map;
const snapshot = {
...map,
history,
};
await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS });
return snapshot;
}),
getWorldTrend: procedure.query(async ({ ctx }) => {
return loadCachedWorldTrend(ctx);
@@ -186,6 +186,50 @@ describe('in-game my information ownership', () => {
})
);
});
it('returns the three legacy front-page record streams for the session-owned general', async () => {
const fixture = createContext({});
const caller = appRouter.createCaller(fixture.context);
await expect(
caller.general.getRecentRecords({
lastGeneralRecordId: 0,
lastWorldHistoryId: 0,
})
).resolves.toEqual({
global: [{ id: 1, text: '기록' }],
general: [{ id: 1, text: '기록' }],
history: [{ id: 1, text: '기록' }],
});
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { scope: 'SYSTEM', category: 'SUMMARY', id: { gte: 0 } },
orderBy: { id: 'desc' },
take: 16,
select: { id: true, text: true },
})
);
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { scope: 'GENERAL', category: 'ACTION', generalId: 7, id: { gte: 0 } },
orderBy: { id: 'desc' },
take: 16,
select: { id: true, text: true },
})
);
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
where: { scope: 'SYSTEM', category: 'HISTORY', id: { gte: 0 } },
orderBy: { id: 'desc' },
take: 16,
select: { id: true, text: true },
})
);
});
});
describe('battle-center general and user permissions', () => {
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'session-owner',
user: {
id: 'owner',
username: 'owner',
displayName: 'Owner',
roles: [],
},
sanctions: {},
};
type LogQuery = {
where: {
scope: LogScope;
category: LogCategory;
generalId?: number;
id: { gte: number };
};
orderBy: { id: 'desc' };
take: number;
select: { id: true; text: true };
};
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
({
auth,
db: {
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
id: 7,
userId: where.userId,
})),
},
logEntry: { findMany },
} as unknown as DatabaseClient,
}) as GameApiContext;
describe('general.getRecentRecords', () => {
it('derives the general and maps all three legacy dashboard buckets', async () => {
const findMany = vi.fn(async (query: LogQuery) => {
if (query.where.scope === LogScope.GENERAL) {
return [
{ id: 31, text: '개인 최신' },
{ id: 20, text: '개인 cursor' },
];
}
if (query.where.category === LogCategory.SUMMARY) {
return [
{ id: 32, text: '장수 최신' },
{ id: 20, text: '장수 cursor' },
];
}
return [
{ id: 42, text: '중원 최신' },
{ id: 40, text: '중원 cursor' },
];
});
const caller = appRouter.createCaller(buildContext(findMany));
const result = await caller.general.getRecentRecords({
lastGeneralRecordId: 20,
lastWorldHistoryId: 40,
});
expect(result).toEqual({
global: [{ id: 32, text: '장수 최신' }],
general: [{ id: 31, text: '개인 최신' }],
history: [{ id: 42, text: '중원 최신' }],
});
expect(findMany).toHaveBeenCalledTimes(3);
expect(findMany).toHaveBeenCalledWith({
where: {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: 7,
id: { gte: 20 },
},
orderBy: { id: 'desc' },
take: 16,
select: { id: true, text: true },
});
});
it('caps an initial bucket at the legacy 15-row limit', async () => {
const rows = Array.from({ length: 16 }, (_, index) => ({
id: 100 - index,
text: `기록 ${index}`,
}));
const caller = appRouter.createCaller(buildContext(async () => rows));
const result = await caller.general.getRecentRecords({
lastGeneralRecordId: 0,
lastWorldHistoryId: 0,
});
expect(result.global).toHaveLength(15);
expect(result.general).toHaveLength(15);
expect(result.history).toHaveLength(15);
expect(result.global.at(-1)?.id).toBe(86);
});
it('rejects an authenticated user without an in-game general', async () => {
const context = buildContext(async () => []);
context.db = {
general: {
findFirst: vi.fn(async () => null),
},
logEntry: {
findMany: vi.fn(async () => []),
},
} as unknown as DatabaseClient;
const caller = appRouter.createCaller(context);
await expect(
caller.general.getRecentRecords({
lastGeneralRecordId: 0,
lastWorldHistoryId: 0,
})
).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
});
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } 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 { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.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 buildContext = () => {
const redis = {
get: vi.fn(async () => null),
set: vi.fn(async () => 'OK'),
};
const db = {
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 190,
currentMonth: 3,
config: {},
meta: { scenarioMeta: { startYear: 184 } },
})),
},
logEntry: {
findMany: vi.fn(async () => [
{ id: 9, text: '<Y>최근 정세</>' },
{ id: 8, text: '이전 정세' },
]),
},
$queryRaw: vi
.fn()
.mockResolvedValueOnce([
{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } },
])
.mockResolvedValueOnce([
{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} },
]),
};
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: redis as unknown as RedisConnector['client'],
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: null as GameSessionTokenPayload | null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redis as unknown as RedisConnector['client'], profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, db, redis };
};
describe('public.getCachedMap', () => {
it('caches the neutral map and ten latest public history rows as one snapshot', async () => {
const fixture = buildContext();
const result = await appRouter.createCaller(fixture.context).public.getCachedMap();
expect(result).toMatchObject({
year: 190,
month: 3,
history: [
{ id: 9, text: '<Y>최근 정세</>' },
{ id: 8, text: '이전 정세' },
],
});
expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith({
where: { scope: 'SYSTEM', category: 'HISTORY' },
select: { id: true, text: true },
orderBy: { id: 'desc' },
take: 10,
});
expect(fixture.redis.set).toHaveBeenCalledWith(
'sammo:public:cachedMapWithHistory:che:default',
expect.stringContaining('최근 정세'),
{ EX: 600 }
);
});
});