feat: implement public map features including caching, API integration, and UI components
This commit is contained in:
@@ -43,6 +43,7 @@ type GeneralCityRow = {
|
||||
|
||||
const MAP_VERSION = 0 as const;
|
||||
const BASE_MAP_TTL_SECONDS = 30;
|
||||
const PUBLIC_MAP_TTL_SECONDS = 600;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -94,11 +95,21 @@ const resolveSpyList = (meta: Record<string, unknown>): Record<number, number> =
|
||||
return {};
|
||||
};
|
||||
|
||||
const buildBaseMapCacheKey = (ctx: GameApiContext): string =>
|
||||
`sammo:map:base:${ctx.profile.id}:${ctx.profile.scenario}`;
|
||||
const buildBaseMapCacheKey = (ctx: GameApiContext, scope: 'base' | 'public' = 'base'): string =>
|
||||
`sammo:map:${scope}:${ctx.profile.id}:${ctx.profile.scenario}`;
|
||||
|
||||
const loadBaseMap = async (
|
||||
ctx: GameApiContext,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
cacheKey?: string;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
): Promise<BaseMapResult | null> => {
|
||||
const useCache = options?.useCache ?? true;
|
||||
const cacheKey = options?.cacheKey ?? buildBaseMapCacheKey(ctx);
|
||||
const ttlSeconds = options?.ttlSeconds ?? BASE_MAP_TTL_SECONDS;
|
||||
|
||||
const loadBaseMap = async (ctx: GameApiContext, useCache: boolean): Promise<BaseMapResult | null> => {
|
||||
const cacheKey = buildBaseMapCacheKey(ctx);
|
||||
if (useCache) {
|
||||
const cached = await ctx.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
@@ -161,13 +172,21 @@ const loadBaseMap = async (ctx: GameApiContext, useCache: boolean): Promise<Base
|
||||
|
||||
if (useCache) {
|
||||
await ctx.redis.set(cacheKey, JSON.stringify(baseMap), {
|
||||
EX: BASE_MAP_TTL_SECONDS,
|
||||
EX: ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
return baseMap;
|
||||
};
|
||||
|
||||
export const loadPublicMap = async (ctx: GameApiContext, useCache = true): Promise<BaseMapResult | null> => {
|
||||
return loadBaseMap(ctx, {
|
||||
useCache,
|
||||
cacheKey: buildBaseMapCacheKey(ctx, 'public'),
|
||||
ttlSeconds: PUBLIC_MAP_TTL_SECONDS,
|
||||
});
|
||||
};
|
||||
|
||||
export const loadWorldMap = async (
|
||||
ctx: GameApiContext,
|
||||
options: {
|
||||
@@ -177,7 +196,7 @@ export const loadWorldMap = async (
|
||||
useCache?: boolean;
|
||||
}
|
||||
): Promise<WorldMapResult | null> => {
|
||||
const baseMap = await loadBaseMap(ctx, options.useCache ?? true);
|
||||
const baseMap = await loadBaseMap(ctx, { useCache: options.useCache ?? true });
|
||||
if (!baseMap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { joinRouter } from './router/join/index.js';
|
||||
import { lobbyRouter } from './router/lobby/index.js';
|
||||
import { messagesRouter } from './router/messages/index.js';
|
||||
import { nationRouter } from './router/nation/index.js';
|
||||
import { publicRouter } from './router/public/index.js';
|
||||
import { troopRouter } from './router/troop/index.js';
|
||||
import { turnDaemonRouter } from './router/turnDaemon/index.js';
|
||||
import { turnsRouter } from './router/turns/index.js';
|
||||
@@ -15,6 +16,7 @@ import { worldRouter } from './router/world/index.js';
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
lobby: lobbyRouter,
|
||||
public: publicRouter,
|
||||
join: joinRouter,
|
||||
battle: battleRouter,
|
||||
world: worldRouter,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
|
||||
type WorldTrendSnapshot = {
|
||||
year: number;
|
||||
month: number;
|
||||
userCnt: number;
|
||||
maxUserCnt: number;
|
||||
npcCnt: number;
|
||||
nationCnt: number;
|
||||
turnTerm: number;
|
||||
fictionMode: string;
|
||||
starttime: string;
|
||||
opentime: string;
|
||||
turntime: string;
|
||||
otherTextInfo: string;
|
||||
isUnited: number;
|
||||
};
|
||||
|
||||
type NationSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
level: number;
|
||||
capitalCityId: number;
|
||||
generalCount: number;
|
||||
cityCount: number;
|
||||
};
|
||||
|
||||
type NationCountRow = {
|
||||
nationId: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const PUBLIC_CACHE_TTL_SECONDS = 600;
|
||||
|
||||
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
|
||||
`sammo:public:${key}:${ctx.profile.id}:${ctx.profile.scenario}`;
|
||||
|
||||
const loadWorldTrendSnapshot = async (ctx: GameApiContext): Promise<WorldTrendSnapshot> => {
|
||||
const rawWorldState = await ctx.db.worldState.findFirst();
|
||||
if (!rawWorldState) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'World state not found',
|
||||
});
|
||||
}
|
||||
|
||||
const config = zWorldStateConfig.parse(rawWorldState.config);
|
||||
const meta = zWorldStateMeta.parse(rawWorldState.meta);
|
||||
|
||||
const [userCnt, npcCnt, nationCnt] = await Promise.all([
|
||||
ctx.db.general.count({ where: { npcState: 0 } }),
|
||||
ctx.db.general.count({ where: { npcState: { gt: 0 } } }),
|
||||
ctx.db.nation.count({ where: { level: { gt: 0 } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
year: rawWorldState.currentYear,
|
||||
month: rawWorldState.currentMonth,
|
||||
userCnt,
|
||||
maxUserCnt: config.maxUserCnt ?? 500,
|
||||
npcCnt,
|
||||
nationCnt,
|
||||
turnTerm: rawWorldState.tickSeconds / 60,
|
||||
fictionMode: config.fictionMode ?? '사실',
|
||||
starttime: meta.starttime ?? '',
|
||||
opentime: meta.opentime ?? '',
|
||||
turntime: meta.turntime ?? '',
|
||||
otherTextInfo: meta.otherTextInfo ?? '',
|
||||
isUnited: meta.isUnited ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const loadCachedWorldTrend = async (ctx: GameApiContext): Promise<WorldTrendSnapshot> => {
|
||||
const cacheKey = buildPublicCacheKey(ctx, 'worldTrend');
|
||||
const cached = await ctx.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached) as WorldTrendSnapshot;
|
||||
} catch {
|
||||
// Ignore cache parse errors.
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await loadWorldTrendSnapshot(ctx);
|
||||
await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS });
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const loadCachedNationList = async (ctx: GameApiContext): Promise<NationSummary[]> => {
|
||||
const cacheKey = buildPublicCacheKey(ctx, 'nationList');
|
||||
const cached = await ctx.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached) as NationSummary[];
|
||||
} catch {
|
||||
// Ignore cache parse errors.
|
||||
}
|
||||
}
|
||||
|
||||
const [nations, generalCounts, cityCounts] = await Promise.all([
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
capitalCityId: true,
|
||||
},
|
||||
orderBy: [{ level: 'desc' }, { id: 'asc' }],
|
||||
}),
|
||||
ctx.db.$queryRaw<NationCountRow[]>`
|
||||
SELECT nation_id as "nationId", COUNT(*)::int as "count"
|
||||
FROM general
|
||||
GROUP BY nation_id
|
||||
`,
|
||||
ctx.db.$queryRaw<NationCountRow[]>`
|
||||
SELECT nation_id as "nationId", COUNT(*)::int as "count"
|
||||
FROM city
|
||||
GROUP BY nation_id
|
||||
`,
|
||||
]);
|
||||
|
||||
const generalCountMap = new Map<number, number>();
|
||||
for (const row of generalCounts) {
|
||||
generalCountMap.set(row.nationId, row.count);
|
||||
}
|
||||
|
||||
const cityCountMap = new Map<number, number>();
|
||||
for (const row of cityCounts) {
|
||||
cityCountMap.set(row.nationId, row.count);
|
||||
}
|
||||
|
||||
const summary = nations.map((nation) => ({
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
level: nation.level,
|
||||
capitalCityId: nation.capitalCityId ?? 0,
|
||||
generalCount: generalCountMap.get(nation.id) ?? 0,
|
||||
cityCount: cityCountMap.get(nation.id) ?? 0,
|
||||
}));
|
||||
|
||||
await ctx.redis.set(cacheKey, JSON.stringify(summary), { EX: PUBLIC_CACHE_TTL_SECONDS });
|
||||
return summary;
|
||||
};
|
||||
|
||||
export const publicRouter = router({
|
||||
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||
return loadMapLayout(ctx.profile.scenario);
|
||||
}),
|
||||
getCachedMap: procedure.query(async ({ ctx }) => {
|
||||
const map = await loadPublicMap(ctx, true);
|
||||
if (!map) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}),
|
||||
getWorldTrend: procedure.query(async ({ ctx }) => {
|
||||
return loadCachedWorldTrend(ctx);
|
||||
}),
|
||||
getNationList: procedure.query(async ({ ctx }) => {
|
||||
return loadCachedNationList(ctx);
|
||||
}),
|
||||
getGeneralList: procedure.query(async ({ ctx }) => {
|
||||
const [generals, nations] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const nationMap = new Map<number, string>();
|
||||
for (const nation of nations) {
|
||||
nationMap.set(nation.id, nation.name);
|
||||
}
|
||||
|
||||
return generals.map((general) => ({
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
nationId: general.nationId,
|
||||
nationName: nationMap.get(general.nationId) ?? '무주',
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user