merge: 최신 main을 브라우저 전투 시뮬레이터 작업에 통합

This commit is contained in:
2026-08-17 01:47:07 +00:00
27 changed files with 1161 additions and 91 deletions
+18
View File
@@ -69,6 +69,24 @@ export const toPublicRealtimeEvent = (
: null;
}
if (event.type === 'tournamentChanged') {
return {
type: 'readModelInvalidated',
invalidation: {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: true,
},
};
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
+4 -2
View File
@@ -6,7 +6,6 @@ import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import {
accessAuthedProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
@@ -790,7 +789,10 @@ export const generalRouter = router({
history: trimRecentRecords(history, input.lastWorldHistoryId),
};
}),
getFrontStatus: accessAuthedProcedure.query(async ({ ctx }) => {
// 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을
// 호출한다. 클라이언트가 주장하는 갱신 원인을 신뢰해 구분하지 않고 이
// projection 전체를 무가점으로 두되, 이미 제한된 사용자의 gate는 유지한다.
getFrontStatus: accessLimitAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
@@ -40,7 +40,6 @@ export const generalAccessEndpointWeights = {
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
@@ -86,14 +85,11 @@ export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
'diplomacy.rollbackLetter',
'diplomacy.destroyLetter',
'betting.getList',
'general.getFrontStatus',
'yearbook.getHistory',
'messages.send',
'turns.getCommandTable',
]);
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
export type GeneralAccessState = {
generalId: number;
refreshScore: number;
+4
View File
@@ -1,3 +1,5 @@
import { buildGameEventChannel } from '@sammo-ts/common';
export interface TournamentKeys {
stateKey: string;
participantsKey: string;
@@ -5,6 +7,7 @@ export interface TournamentKeys {
bettingKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
}
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -14,4 +17,5 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
});
+1 -11
View File
@@ -9,7 +9,6 @@ import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
import {
formatGeneralAccessLimitMessage,
generalAccessLimitBeforeRecordEndpoints,
generalAccessLimitEndpoints,
getGeneralAccessState,
recordGeneralAccessWeight,
@@ -103,17 +102,8 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
return next();
}
const endpoint = path as GeneralAccessEndpoint;
if (generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
await recordGeneralAccessWeight(ctx, weight);
if (generalAccessLimitEndpoints.has(endpoint) && !generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
if (generalAccessLimitEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
@@ -383,6 +383,10 @@ integration('general access tracking persistence', () => {
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
const dashboardCaller = appRouter.createCaller(context);
await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined();
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
@@ -101,7 +101,6 @@ describe('general access tracking', () => {
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
@@ -131,6 +130,7 @@ describe('general access tracking', () => {
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull();
expect(resolveGeneralAccessEndpointWeight('general.getFrontStatus', {}, 'che')).toBeUndefined();
expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined();
});
@@ -52,6 +52,7 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
},
});
const serialized = JSON.stringify(publicEvent);
@@ -108,10 +109,33 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true,
records: true,
frontStatus: true,
tournament: true,
},
});
});
it('redacts tournament state changes to one global boolean invalidation', () => {
const publicEvent = toPublicRealtimeEvent({ type: 'tournamentChanged' }, [viewer]);
expect(publicEvent).toEqual({
type: 'readModelInvalidated',
invalidation: {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: true,
},
});
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId/u);
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
});
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
const event: RealtimeEvent = {
type: 'messageCreated',
+6
View File
@@ -42,6 +42,12 @@ describe('parseRealtimeEvent', () => {
expect(parseRealtimeEvent('not-json')).toBeNull();
expect(parseRealtimeEvent(JSON.stringify({}))).toBeNull();
});
it('accepts the minimal tournament state wake-up', () => {
expect(parseRealtimeEvent(JSON.stringify({ type: 'tournamentChanged' }))).toEqual({
type: 'tournamentChanged',
});
});
});
describe('buildGameEventChannel', () => {
@@ -10,12 +10,27 @@ const integration = describe.skipIf(!process.env.REDIS_URL);
integration('TournamentStore Redis source revision', () => {
let connector: RedisConnector;
let subscriber: RedisConnector;
const profile = `test:tournament-revision:${randomUUID()}`;
const keys = buildTournamentKeys(profile);
const sourceMessages: string[] = [];
const realtimeMessages: string[] = [];
const waitForLength = async (values: readonly string[], length: number): Promise<void> => {
const deadline = Date.now() + 1_000;
while (values.length < length && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(values).toHaveLength(length);
};
beforeAll(async () => {
connector = createRedisConnector(resolveRedisConfigFromEnv());
subscriber = createRedisConnector(resolveRedisConfigFromEnv());
await connector.connect();
await subscriber.connect();
await subscriber.client.subscribe(keys.sourceRevisionChannel, (message) => sourceMessages.push(message));
await subscriber.client.subscribe(keys.realtimeEventChannel, (message) => realtimeMessages.push(message));
});
afterAll(async () => {
@@ -27,6 +42,11 @@ integration('TournamentStore Redis source revision', () => {
keys.bettingKey,
keys.sourceRevisionKey,
]);
if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
await subscriber.client.unsubscribe(keys.realtimeEventChannel);
await subscriber.disconnect();
}
await connector.disconnect();
});
@@ -44,4 +64,37 @@ integration('TournamentStore Redis source revision', () => {
await expect(store.getSourceRevision()).resolves.toBe('20');
await expect(store.getMatches()).resolves.toHaveLength(1);
});
it('publishes the main wake-up only when the atomic state write changes stage', async () => {
const store = new TournamentStore(connector.client, keys);
const sourceBefore = sourceMessages.length;
const realtimeBefore = realtimeMessages.length;
const baseState = {
stage: 1,
phase: 0,
type: 0 as const,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
};
await store.setState(baseState);
await waitForLength(sourceMessages, sourceBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 1);
await store.setState({ ...baseState, phase: 1 });
await waitForLength(sourceMessages, sourceBefore + 2);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
await store.setState({ ...baseState, stage: 2, phase: 0 });
await waitForLength(sourceMessages, sourceBefore + 3);
await waitForLength(realtimeMessages, realtimeBefore + 2);
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
JSON.stringify({ type: 'tournamentChanged' }),
JSON.stringify({ type: 'tournamentChanged' }),
]);
});
});
@@ -35,7 +35,8 @@ class AtomicMemoryRedis {
}
async publish(channel: string, message: string): Promise<number> {
this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`);
const payload = JSON.parse(message) as { sourceRevision?: string; type?: string };
this.events.push(`publish:${payload.sourceRevision ?? payload.type ?? 'unknown'}`);
this.published.push({ channel, message });
return 1;
}
@@ -71,6 +72,29 @@ describe('TournamentStore source revision', () => {
expect(redis.published).toEqual([]);
});
it('wakes the main realtime channel only for shared state writes', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('che:default');
const store = new TournamentStore(redis, keys);
await store.setState({
stage: 1,
phase: 0,
type: 0,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
});
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
it('serializes concurrent writes into monotonic per-profile revisions', async () => {
const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('pwe:default'));