perf: 공유 지도와 토너먼트 revision을 원자화

지도 캐시는 coverage가 확인된 PostgreSQL map.world head만 사용하고 실패 시 전체 계산으로 복구한다. 토너먼트 Redis payload와 source revision은 Lua 한 번으로 갱신한다.
This commit is contained in:
2026-08-16 18:37:48 +00:00
parent 7e099326ec
commit e4ac2a60b1
13 changed files with 482 additions and 55 deletions
+32 -24
View File
@@ -1,5 +1,6 @@
import type { GameApiContext, WorldStateRow } from '../context.js';
import { asRecord, buildGameReadModelDomainRevisionKey, isRecord } from '@sammo-ts/common';
import { asRecord, isRecord } from '@sammo-ts/common';
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
export type MapCityCompact = [number, number, number, number, number, number];
export type MapNationCompact = [number, string, string, number];
@@ -116,38 +117,41 @@ const resolveSpyList = (meta: Record<string, unknown>): Record<number, number> =
const buildBaseMapCacheKey = (ctx: GameApiContext, scope: 'base' | 'public' = 'base'): string =>
`sammo:map:${scope}:${ctx.profile.id}:${ctx.profile.scenario}`;
const loadWorldMapRevision = async (ctx: GameApiContext): Promise<string> => {
const redis = ctx.redis as unknown as {
hGet?: (key: string, field: string) => Promise<string | null>;
};
if (typeof redis.hGet !== 'function') {
return '0';
}
try {
return (await redis.hGet(buildGameReadModelDomainRevisionKey(ctx.profile.name), 'world')) ?? '0';
} catch {
// Cache revision lookup must not make the map unavailable.
return '0';
}
export const buildRevisionedBaseMapCacheKey = async (
ctx: GameApiContext,
scope: 'base' | 'public' = 'base'
): Promise<string | null> => {
const revision = await readMapWorldSourceRevision(ctx.db);
return revision === null ? null : `${buildBaseMapCacheKey(ctx, scope)}:pg${revision}`;
};
export const buildRevisionedBaseMapCacheKey = async (ctx: GameApiContext): Promise<string> =>
`${buildBaseMapCacheKey(ctx)}:r${await loadWorldMapRevision(ctx)}`;
const loadBaseMap = async (
ctx: GameApiContext,
options?: {
useCache?: boolean;
cacheKey?: string;
cacheScope?: 'base' | 'public';
ttlSeconds?: number;
}
): Promise<BaseMapResult | null> => {
const useCache = options?.useCache ?? true;
const cacheKey = options?.cacheKey ?? (await buildRevisionedBaseMapCacheKey(ctx));
let useCache = options?.useCache ?? true;
let cacheKey = options?.cacheKey;
if (useCache && !cacheKey) {
cacheKey = (await buildRevisionedBaseMapCacheKey(ctx, options?.cacheScope)) ?? undefined;
if (!cacheKey) {
useCache = false;
}
}
const ttlSeconds = options?.ttlSeconds ?? BASE_MAP_TTL_SECONDS;
if (useCache) {
const cached = await ctx.redis.get(cacheKey);
let cached: string | null = null;
try {
cached = await ctx.redis.get(cacheKey!);
} catch {
// Redis cache availability must not make the authoritative map unavailable.
useCache = false;
}
if (cached) {
try {
return JSON.parse(cached) as BaseMapResult;
@@ -208,9 +212,13 @@ const loadBaseMap = async (
};
if (useCache) {
await ctx.redis.set(cacheKey, JSON.stringify(baseMap), {
EX: ttlSeconds,
});
try {
await ctx.redis.set(cacheKey!, JSON.stringify(baseMap), {
EX: ttlSeconds,
});
} catch {
// The computed PostgreSQL result remains usable when Redis is unavailable.
}
}
return baseMap;
@@ -219,7 +227,7 @@ const loadBaseMap = async (
export const loadPublicMap = async (ctx: GameApiContext, useCache = true): Promise<BaseMapResult | null> => {
return loadBaseMap(ctx, {
useCache,
cacheKey: buildBaseMapCacheKey(ctx, 'public'),
cacheScope: 'public',
ttlSeconds: PUBLIC_MAP_TTL_SECONDS,
});
};
@@ -0,0 +1,54 @@
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
/** Reserved until every map.world producer is reconciled; runtime coverage remains 0. */
export const MAP_WORLD_SOURCE_COVERAGE_VERSION = 1;
interface MapWorldSourceRevisionRow {
coverageVersion: number;
revision: bigint | number | string | null;
}
const parseRevision = (value: unknown): string | null => {
if (typeof value === 'bigint') return value >= 0n ? value.toString() : null;
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
}
return typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value) ? value : null;
};
/**
* Returns an authoritative PostgreSQL map.world head only after coverage is
* explicitly enabled. Missing meta/revision rows, malformed results, and query
* failures disable the shared cache instead of reusing a potentially stale key.
*/
export const readMapWorldSourceRevision = async (
db: Pick<DatabaseClient, '$queryRaw'>
): Promise<string | null> => {
let rows: MapWorldSourceRevisionRow[];
try {
rows = await db.$queryRaw<MapWorldSourceRevisionRow[]>(GamePrisma.sql`
SELECT
meta."coverage_version" AS "coverageVersion",
revision."revision" AS "revision"
FROM "read_model_revision_meta" AS meta
LEFT JOIN "read_model_revision" AS revision
ON revision."domain" = 'map.world'
AND revision."entity_id" = 0
WHERE meta."id" = 1
`);
} catch {
return null;
}
const row = Array.isArray(rows) && rows.length === 1 ? rows[0] : undefined;
if (
!row ||
!Number.isSafeInteger(row.coverageVersion) ||
row.coverageVersion < MAP_WORLD_SOURCE_COVERAGE_VERSION
) {
return null;
}
return parseRevision(row.revision);
};
+3 -2
View File
@@ -131,11 +131,12 @@ export const tournamentRouter = router({
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, participants, matches, bets] = await Promise.all([
const [state, participants, matches, bets, sourceRevision] = await Promise.all([
store.getState(),
store.getParticipants(),
store.getMatches(),
store.getBettingEntries(),
store.getSourceRevision(),
]);
const participantIds = [...new Set(participants.map((participant) => participant.id))];
const iconRows =
@@ -154,7 +155,7 @@ export const tournamentRouter = router({
imageServer: icon?.imageServer ?? 0,
};
});
return { state, participants: publicParticipants, matches, betCount: bets.length };
return { state, participants: publicParticipants, matches, betCount: bets.length, sourceRevision };
}),
getRankings: authedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx);
+4
View File
@@ -3,6 +3,8 @@ export interface TournamentKeys {
participantsKey: string;
matchesKey: string;
bettingKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
}
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -10,4 +12,6 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
participantsKey: `sammo:${profileName}:tournament:participants`,
matchesKey: `sammo:${profileName}:tournament:matches`,
bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
});
+62 -8
View File
@@ -14,8 +14,35 @@ interface RedisClientLike {
}
): Promise<unknown>;
del?(key: string): Promise<unknown>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
publish?(channel: string, message: string): Promise<unknown>;
}
const writeWithSourceRevisionScript = `
local current = redis.call('GET', KEYS[2])
if current then
if not string.match(current, '^%d+$') then
return redis.error_reply('invalid tournament source revision')
end
if string.len(current) > 18 then
return redis.error_reply('tournament source revision exhausted')
end
end
redis.call('SET', KEYS[1], ARGV[1])
local revision = redis.call('INCR', KEYS[2])
return tostring(revision)
`;
const parseSourceRevision = (value: unknown): string | null => {
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
}
if (typeof value === 'bigint') {
return value >= 0n ? value.toString() : null;
}
return typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value) ? value : null;
};
const safeJsonParse = <T>(raw: string | null): T | null => {
if (!raw) {
return null;
@@ -61,32 +88,59 @@ export class TournamentStore {
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
}
async setState(state: TournamentState): Promise<void> {
await this.redis.set(this.keys.stateKey, JSON.stringify(state));
async getSourceRevision(): Promise<string | null> {
return parseSourceRevision(await this.redis.get(this.keys.sourceRevisionKey));
}
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
const result = await this.redis.eval(writeWithSourceRevisionScript, {
keys: [key, this.keys.sourceRevisionKey],
arguments: [JSON.stringify(value)],
});
const sourceRevision = parseSourceRevision(result);
if (sourceRevision === null) {
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
}
if (this.redis.publish) {
try {
await this.redis.publish(
this.keys.sourceRevisionChannel,
JSON.stringify({ sourceRevision })
);
} catch {
// State and revision are already committed atomically; publication is best effort.
}
}
return sourceRevision;
}
async setState(state: TournamentState): Promise<string> {
return this.writeWithSourceRevision(this.keys.stateKey, state);
}
async getParticipants(): Promise<TournamentParticipantEntry[]> {
return safeJsonParse<TournamentParticipantEntry[]>(await this.redis.get(this.keys.participantsKey)) ?? [];
}
async setParticipants(participants: TournamentParticipantEntry[]): Promise<void> {
await this.redis.set(this.keys.participantsKey, JSON.stringify(participants));
async setParticipants(participants: TournamentParticipantEntry[]): Promise<string> {
return this.writeWithSourceRevision(this.keys.participantsKey, participants);
}
async getMatches(): Promise<TournamentMatchEntry[]> {
return safeJsonParse<TournamentMatchEntry[]>(await this.redis.get(this.keys.matchesKey)) ?? [];
}
async setMatches(matches: TournamentMatchEntry[]): Promise<void> {
await this.redis.set(this.keys.matchesKey, JSON.stringify(matches));
async setMatches(matches: TournamentMatchEntry[]): Promise<string> {
return this.writeWithSourceRevision(this.keys.matchesKey, matches);
}
async getBettingEntries(): Promise<TournamentBetEntry[]> {
return safeJsonParse<TournamentBetEntry[]>(await this.redis.get(this.keys.bettingKey)) ?? [];
}
async setBettingEntries(entries: TournamentBetEntry[]): Promise<void> {
await this.redis.set(this.keys.bettingKey, JSON.stringify(entries));
async setBettingEntries(entries: TournamentBetEntry[]): Promise<string> {
return this.writeWithSourceRevision(this.keys.bettingKey, entries);
}
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
@@ -160,6 +160,7 @@ const context = (
diplomacy: { findMany: vi.fn(async () => []) },
$queryRaw: vi
.fn()
.mockResolvedValueOnce([{ coverageVersion: 0, revision: null }])
.mockResolvedValueOnce(
cities.map((item) => ({
id: item.id,
@@ -44,6 +44,7 @@ const buildContext = () => {
},
$queryRaw: vi
.fn()
.mockResolvedValueOnce([{ coverageVersion: 0, revision: null }])
.mockResolvedValueOnce([{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }])
.mockResolvedValueOnce([{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }]),
};
@@ -28,6 +28,20 @@ class MemoryRedis {
async del(key: string): Promise<number> {
return this.values.delete(key) ? 1 : 0;
}
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
const [valueKey, revisionKey] = options.keys;
const [value] = options.arguments;
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
this.values.set(valueKey, value);
this.values.set(revisionKey, String(revision));
return String(revision);
}
async publish(): Promise<number> {
return 0;
}
}
class TournamentTransport implements TurnDaemonTransport {
@@ -324,6 +338,7 @@ describe('tournament router permissions and mutations', () => {
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
});
await redis.set('sammo:che:default:tournament:source-revision', '41');
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' })
);
@@ -334,6 +349,7 @@ describe('tournament router permissions and mutations', () => {
expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }),
expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }),
]);
expect(snapshot.sourceRevision).toBe('41');
});
it('refunds gold when the tournament bet rank update fails', async () => {
@@ -0,0 +1,47 @@
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createRedisConnector, resolveRedisConfigFromEnv, type RedisConnector } from '@sammo-ts/infra';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import { TournamentStore } from '../src/tournament/store.js';
const integration = describe.skipIf(!process.env.REDIS_URL);
integration('TournamentStore Redis source revision', () => {
let connector: RedisConnector;
const profile = `test:tournament-revision:${randomUUID()}`;
const keys = buildTournamentKeys(profile);
beforeAll(async () => {
connector = createRedisConnector(resolveRedisConfigFromEnv());
await connector.connect();
});
afterAll(async () => {
if (!connector) return;
await connector.client.del([
keys.stateKey,
keys.participantsKey,
keys.matchesKey,
keys.bettingKey,
keys.sourceRevisionKey,
]);
await connector.disconnect();
});
it('commits concurrent payload writes with unique monotonic revisions', async () => {
const store = new TournamentStore(connector.client, keys);
const revisions = await Promise.all(
Array.from({ length: 20 }, (_, index) =>
store.setMatches([
{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 },
])
)
);
expect(new Set(revisions).size).toBe(20);
await expect(store.getSourceRevision()).resolves.toBe('20');
await expect(store.getMatches()).resolves.toHaveLength(1);
});
});
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import { TournamentStore } from '../src/tournament/store.js';
class AtomicMemoryRedis {
readonly events: string[] = [];
readonly published: Array<{ channel: string; message: string }> = [];
failNextEval = false;
private readonly values = new Map<string, string>();
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async set(key: string, value: string): Promise<string> {
this.values.set(key, value);
return 'OK';
}
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
if (this.failNextEval) {
this.failNextEval = false;
throw new Error('injected Redis write failure');
}
const [valueKey, revisionKey] = options.keys;
const [value] = options.arguments;
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
this.values.set(valueKey, value);
this.values.set(revisionKey, String(revision));
this.events.push(`commit:${revision}`);
return String(revision);
}
async publish(channel: string, message: string): Promise<number> {
this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`);
this.published.push({ channel, message });
return 1;
}
}
describe('TournamentStore source revision', () => {
it('publishes only after the payload and source revision commit atomically', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('che:default');
const store = new TournamentStore(redis, keys);
await expect(store.setParticipants([{ id: 7, name: '관우', leadership: 90, strength: 97, intel: 75, level: 5 }]))
.resolves.toBe('1');
await expect(store.getSourceRevision()).resolves.toBe('1');
await expect(store.getParticipants()).resolves.toHaveLength(1);
expect(redis.events).toEqual(['commit:1', 'publish:1']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
]);
});
it('does not advance the source revision or publish when the atomic write fails', async () => {
const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('hwe:default'));
redis.failNextEval = true;
await expect(store.setParticipants([{ id: 1, name: '실패', leadership: 1, strength: 1, intel: 1, level: 1 }]))
.rejects.toThrow('injected Redis write failure');
await expect(store.getParticipants()).resolves.toEqual([]);
await expect(store.getSourceRevision()).resolves.toBeNull();
expect(redis.published).toEqual([]);
});
it('serializes concurrent writes into monotonic per-profile revisions', async () => {
const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('pwe:default'));
const revisions = await Promise.all(
Array.from({ length: 50 }, (_, index) =>
store.setMatches([
{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 },
])
)
);
expect(new Set(revisions).size).toBe(50);
await expect(store.getSourceRevision()).resolves.toBe('50');
expect(redis.published).toHaveLength(50);
});
});
@@ -26,6 +26,20 @@ class MemoryRedis {
this.store.set(key, value);
return 'OK';
}
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
const [valueKey, revisionKey] = options.keys;
const [value] = options.arguments;
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
const revision = Number(this.store.get(revisionKey) ?? '0') + 1;
this.store.set(valueKey, value);
this.store.set(revisionKey, String(revision));
return String(revision);
}
async publish(): Promise<number> {
return 0;
}
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
@@ -0,0 +1,41 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('world map PostgreSQL source revision', () => {
let db: GamePrismaClient;
let close: (() => Promise<void>) | undefined;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
});
afterAll(async () => close?.());
it('reads coverage and map.world from the same transaction snapshot', async () => {
const rollback = new Error('rollback map revision fixture');
await expect(
db.$transaction(async (transaction) => {
await transaction.readModelRevisionMeta.upsert({
where: { id: 1 },
create: { id: 1, coverageVersion: 1 },
update: { coverageVersion: 1 },
});
await transaction.readModelRevision.upsert({
where: { domain_entityId: { domain: 'map.world', entityId: 0 } },
create: { domain: 'map.world', entityId: 0, revision: 37n },
update: { revision: 37n },
});
await expect(readMapWorldSourceRevision(transaction)).resolves.toBe('37');
throw rollback;
})
).rejects.toBe(rollback);
});
});
+117 -21
View File
@@ -1,36 +1,132 @@
import { describe, expect, it } from 'vitest';
import { buildGameReadModelDomainRevisionKey } from '@sammo-ts/common';
import { describe, expect, it, vi } from 'vitest';
import { buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js';
import type { GameApiContext } from '../src/context.js';
import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js';
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
const revisionRow = (overrides: Record<string, unknown> = {}) => ({
coverageVersion: 1,
revision: 12n,
...overrides,
});
describe('world map revision cache', () => {
it('selects a new shared base-map key after a committed world revision', async () => {
const reads: Array<[string, string]> = [];
it('keys shared base and public maps from the authoritative PostgreSQL map.world head', async () => {
const queryRaw = vi.fn(async (_query: unknown) => [revisionRow()]);
const ctx = {
profile: { id: 'hwe', name: 'hwe', scenario: 'scenario_2400' },
redis: {
hGet: async (key: string, field: string) => {
reads.push([key, field]);
return '12';
},
profile: { id: 'hwe', name: 'hwe:default', scenario: 'scenario_2400' },
db: { $queryRaw: queryRaw },
} as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe(
'sammo:map:base:hwe:scenario_2400:pg12'
);
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
'sammo:map:public:hwe:scenario_2400:pg12'
);
expect(queryRaw).toHaveBeenCalledTimes(2);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('read_model_revision_meta');
expect(statement.sql).toContain("revision.\"domain\" = 'map.world'");
expect(statement.values).toEqual([]);
});
it('disables shared caching for coverage zero, missing rows, malformed results, and query errors', async () => {
for (const rows of [
[revisionRow({ coverageVersion: 0 })],
[revisionRow({ revision: null })],
[revisionRow({ revision: 'bad' })],
[],
]) {
await expect(
readMapWorldSourceRevision({ $queryRaw: vi.fn(async (_query: unknown) => rows) } as never)
).resolves.toBeNull();
}
await expect(
readMapWorldSourceRevision({
$queryRaw: vi.fn(async (_query: unknown) => Promise.reject(new Error('db unavailable'))),
} as never)
).resolves.toBeNull();
});
it('caches only the public base while composing viewer-private fields per request', async () => {
const cache = new Map<string, string>();
const redis = {
get: vi.fn(async (key: string) => cache.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => {
cache.set(key, value);
return 'OK';
}),
};
const worldState = {
currentYear: 185,
currentMonth: 4,
config: { const: {} },
meta: { scenarioMeta: { startYear: 184 } },
};
const generals = new Map([
[7, { id: 7, cityId: 3, nationId: 2 }],
[8, { id: 8, cityId: 4, nationId: 3 }],
]);
const nations = new Map([
[2, { id: 2, meta: { spyList: { 5: 9 } } }],
[3, { id: 3, meta: { spyList: { 6: 8 } } }],
]);
const queryRaw = vi.fn(async (statement: { sql?: string }) => {
const sql = statement.sql ?? '';
if (sql.includes('read_model_revision_meta')) return [revisionRow()];
if (sql.includes('FROM city')) {
return [{ id: 3, level: 1, nationId: 2, region: 1, supplyState: 1, meta: { state: 0 } }];
}
if (sql.includes('FROM nation')) {
return [{ id: 2, name: '위', color: '#123456', capitalCityId: 3, meta: {} }];
}
if (sql.includes('SELECT DISTINCT city_id')) return [{ cityId: 3 }];
return [];
});
const ctx = {
profile: { id: 'hwe', name: 'hwe:default', scenario: 'scenario_2400' },
redis,
db: {
$queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => worldState) },
general: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => generals.get(where.id)) },
nation: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => nations.get(where.id)) },
},
} as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe(
'sammo:map:base:hwe:scenario_2400:r12'
);
expect(reads).toEqual([[buildGameReadModelDomainRevisionKey('hwe'), 'world']]);
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
expect(redis.set).toHaveBeenCalledTimes(1);
const shared = JSON.parse(cache.values().next().value as string) as Record<string, unknown>;
expect(shared).not.toHaveProperty('spyList');
expect(shared).not.toHaveProperty('shownByGeneralList');
expect(shared).not.toHaveProperty('myCity');
expect(shared).not.toHaveProperty('myNation');
});
it('falls back to revision zero when Redis is temporarily unavailable', async () => {
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
const redis = { get: vi.fn(), set: vi.fn() };
const queryRaw = vi.fn(async (statement: { sql?: string }) => {
const sql = statement.sql ?? '';
if (sql.includes('read_model_revision_meta')) return [revisionRow({ coverageVersion: 0 })];
if (sql.includes('FROM city') || sql.includes('FROM nation')) return [];
return [];
});
const ctx = {
profile: { id: 'hwe', name: 'hwe', scenario: 'scenario_2400' },
redis: { hGet: async () => Promise.reject(new Error('redis unavailable')) },
profile: { id: 'hwe', name: 'hwe:default', scenario: 'scenario_2400' },
redis,
db: {
$queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) },
},
} as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe(
'sammo:map:base:hwe:scenario_2400:r0'
);
await expect(loadWorldMap(ctx, { useCache: true })).resolves.toMatchObject({ result: true });
expect(redis.get).not.toHaveBeenCalled();
expect(redis.set).not.toHaveBeenCalled();
});
});