feat: complete map and trend frontend flows
This commit is contained in:
@@ -15,6 +15,7 @@ const zGeneralSettings = z.object({
|
||||
});
|
||||
|
||||
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
|
||||
const FRONT_RECORD_LIMIT = 15;
|
||||
|
||||
const readNumber = (value: unknown, fallback: number): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -319,4 +320,49 @@ export const generalRouter = router({
|
||||
})),
|
||||
};
|
||||
}),
|
||||
getFrontRecords: authedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
const select = {
|
||||
id: true,
|
||||
text: true,
|
||||
} as const;
|
||||
const orderBy = { id: 'desc' } as const;
|
||||
|
||||
const [global, general, history] = await Promise.all([
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.ACTION,
|
||||
},
|
||||
select,
|
||||
orderBy,
|
||||
take: FRONT_RECORD_LIMIT,
|
||||
}),
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: me.id,
|
||||
},
|
||||
select,
|
||||
orderBy,
|
||||
take: FRONT_RECORD_LIMIT,
|
||||
}),
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
},
|
||||
select,
|
||||
orderBy,
|
||||
take: FRONT_RECORD_LIMIT,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
global,
|
||||
general,
|
||||
history,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,42 @@ 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.getFrontRecords()).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: 'ACTION' },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 15,
|
||||
})
|
||||
);
|
||||
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: { scope: 'GENERAL', category: 'ACTION', generalId: 7 },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 15,
|
||||
})
|
||||
);
|
||||
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
where: { scope: 'SYSTEM', category: 'HISTORY' },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 15,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('battle-center general and user permissions', () => {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user