feat: 토너먼트 페이지군 자동 갱신을 분리한다

토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
This commit is contained in:
2026-08-22 06:00:22 +00:00
parent 3b2b98dc88
commit 985f627499
19 changed files with 845 additions and 163 deletions
+20
View File
@@ -31,6 +31,14 @@ const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null =>
return null;
};
export type RealtimeSubscriptionScope = 'dashboard' | 'tournament';
/** Keeps page-family projection traffic off the dashboard subscription and vice versa. */
export const shouldForwardRealtimeEvent = (event: RealtimeEvent, scope: RealtimeSubscriptionScope): boolean =>
scope === 'tournament'
? event.type === 'tournamentProjectionChanged'
: event.type !== 'tournamentProjectionChanged';
export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identity: RealtimeViewerIdentity): boolean => {
if (identity.generalId === null) return false;
const changes = eventChanges(event);
@@ -86,6 +94,18 @@ export const toPublicRealtimeEvent = (
};
}
if (event.type === 'tournamentProjectionChanged') {
return {
type: 'tournamentViewInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: {
snapshot: event.invalidation.snapshot === true,
betting: event.invalidation.betting === true,
rankings: event.invalidation.rankings === true,
},
};
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
+8 -2
View File
@@ -33,7 +33,11 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
import {
shouldForwardRealtimeEvent,
shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent,
} from './realtime/publicEvent.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
@@ -267,7 +271,8 @@ export const createGameApiServer = async () => {
});
app.get(config.eventsPath, async (request, reply) => {
const query = request.query as { token?: string };
const query = request.query as { token?: string; scope?: string };
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
const tokenFromHeader = extractBearerToken(request.headers.authorization);
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
@@ -322,6 +327,7 @@ export const createGameApiServer = async () => {
let closed = false;
let eventQueue = Promise.resolve();
const unsubscribe = realtimeHub.subscribe((event) => {
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
eventQueue = eventQueue
.then(async () => {
if (closed) return;
@@ -69,6 +69,12 @@ export const generalAccessEndpointWeights = {
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
/** Server-proven realtime refreshes retain the limit gate but do not add refresh score. */
export const shouldRecordGeneralAccessEndpoint = (
endpoint: GeneralAccessEndpoint,
realtimeAccessGranted: boolean | undefined
): boolean => !(realtimeAccessGranted === true && endpoint === 'tournament.getSnapshot');
export const generalAccessLimitPages = new Set<AccessPage>(['nation-list', 'npc-control']);
export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
+4 -1
View File
@@ -13,6 +13,7 @@ import {
getGeneralAccessState,
recordGeneralAccessWeight,
resolveGeneralAccessEndpointWeight,
shouldRecordGeneralAccessEndpoint,
type GeneralAccessEndpoint,
} from './services/generalAccess.js';
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
@@ -119,7 +120,9 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
return next();
}
const endpoint = path as GeneralAccessEndpoint;
await recordGeneralAccessWeight(ctx, weight);
if (shouldRecordGeneralAccessEndpoint(endpoint, ctx.realtimeAccessGranted)) {
await recordGeneralAccessWeight(ctx, weight);
}
if (generalAccessLimitEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
@@ -6,6 +6,7 @@ import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js';
import {
accessAuthedProcedure,
accessAuthedInputProcedure,
accessLimitAuthedProcedure,
deferredAccessLimitAuthedProcedure,
@@ -20,6 +21,7 @@ import {
resolveGeneralAccessEndpointWeight,
resolveAccessWindows,
resolveGeneralScoreStartedAt,
shouldRecordGeneralAccessEndpoint,
} from '../src/services/generalAccess.js';
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
@@ -144,6 +146,27 @@ describe('general access tracking', () => {
expect(generalAccessEndpointWeights['world.getGeneralDirectory']).toBe(2);
});
it('waives score only for a server-proven tournament snapshot refresh', () => {
expect(shouldRecordGeneralAccessEndpoint('tournament.getSnapshot', true)).toBe(false);
expect(shouldRecordGeneralAccessEndpoint('tournament.getSnapshot', false)).toBe(true);
expect(shouldRecordGeneralAccessEndpoint('world.getGeneralDirectory', true)).toBe(true);
});
it('keeps the existing hard limit gate on a granted tournament refresh', async () => {
const now = new Date();
const fixture = buildDb({ lastTurnTime: now.toISOString() }, { lastRefresh: now, refreshScore: 999 });
const tournamentBoundary = router({
tournament: router({ getSnapshot: accessAuthedProcedure.query(() => ({ ok: true })) }),
});
const caller = tournamentBoundary.createCaller({
...accessContext(fixture.db),
realtimeAccessGranted: true,
} as unknown as GameApiContext);
await expect(caller.tournament.getSnapshot()).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
expect(fixture.executeRaw).not.toHaveBeenCalled();
});
it('uses the latest processed game turn as the traffic period and score window', () => {
expect(
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
@@ -197,7 +220,6 @@ describe('general access tracking', () => {
expect(accessStatement.values).toContain(2);
expect(accessStatement.values).toContain(now);
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
});
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
@@ -248,9 +270,7 @@ describe('general access tracking', () => {
const fixture = buildDb();
const resolver = vi.fn(() => ({ ok: true }));
const limitedRouter = router({ read: deferredAccessLimitAuthedProcedure.query(resolver) });
const get = vi.fn(async () =>
JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' })
);
const get = vi.fn(async () => JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' }));
await expect(
limitedRouter
@@ -4,6 +4,7 @@ import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
import {
shouldForwardRealtimeEvent,
shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js';
@@ -145,6 +146,44 @@ describe('public realtime event privacy boundary', () => {
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
});
it('exposes tournament page refresh selection without projection identity or revision', () => {
const invalidation = { snapshot: true, betting: false, rankings: false, generalId: 7 };
const publicEvent = toPublicRealtimeEvent(
{
type: 'tournamentProjectionChanged',
invalidation,
},
[viewer]
);
expect(publicEvent).toEqual({
type: 'tournamentViewInvalidated',
refreshGrant,
invalidation: { snapshot: true, betting: false, rankings: false },
});
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId|matchId/u);
expect(
shouldReloadRealtimeViewerIdentity(
{
type: 'tournamentProjectionChanged',
invalidation: { snapshot: false, betting: true, rankings: false },
},
viewer
)
).toBe(false);
});
it('routes tournament projection traffic only to the dedicated page-family subscription', () => {
const projectionEvent: RealtimeEvent = {
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: false, rankings: false },
};
expect(shouldForwardRealtimeEvent(projectionEvent, 'dashboard')).toBe(false);
expect(shouldForwardRealtimeEvent(projectionEvent, 'tournament')).toBe(true);
expect(shouldForwardRealtimeEvent({ type: 'tournamentChanged' }, 'dashboard')).toBe(true);
expect(shouldForwardRealtimeEvent({ type: 'tournamentChanged' }, 'tournament')).toBe(false);
});
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
const event: RealtimeEvent = {
type: 'messageCreated',
@@ -54,9 +54,7 @@ integration('TournamentStore Redis source revision', () => {
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 },
])
store.setMatches([{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 }])
)
);
@@ -82,19 +80,48 @@ integration('TournamentStore Redis source revision', () => {
await store.setState(baseState);
await waitForLength(sourceMessages, sourceBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 2);
await store.setState({ ...baseState, phase: 1 });
await waitForLength(sourceMessages, sourceBefore + 2);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 3);
await store.setState({ ...baseState, stage: 2, phase: 0 });
await waitForLength(sourceMessages, sourceBefore + 3);
await waitForLength(realtimeMessages, realtimeBefore + 5);
expect(
realtimeMessages
.slice(realtimeBefore)
.filter((message) => (JSON.parse(message) as { type?: string }).type === 'tournamentChanged')
).toEqual([JSON.stringify({ type: 'tournamentChanged' }), JSON.stringify({ type: 'tournamentChanged' })]);
});
it('selects rankings only on the first committed reward settlement', async () => {
const store = new TournamentStore(connector.client, keys);
const realtimeBefore = realtimeMessages.length;
const state = await store.getState();
expect(state).not.toBeNull();
await store.setState({ ...state!, rewardSettled: true, bettingSettled: false });
await store.setState({ ...state!, rewardSettled: true, bettingSettled: true });
await waitForLength(realtimeMessages, realtimeBefore + 2);
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
JSON.stringify({ type: 'tournamentChanged' }),
JSON.stringify({ type: 'tournamentChanged' }),
const pageEvents = realtimeMessages.slice(realtimeBefore).map(
(message) =>
JSON.parse(message) as {
type: string;
invalidation?: { rankings?: boolean };
}
);
expect(pageEvents).toEqual([
expect.objectContaining({
type: 'tournamentProjectionChanged',
invalidation: expect.objectContaining({ rankings: true }),
}),
expect.objectContaining({
type: 'tournamentProjectionChanged',
invalidation: expect.objectContaining({ rankings: false }),
}),
]);
});
});
@@ -48,14 +48,22 @@ describe('TournamentStore source revision', () => {
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.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.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentProjectionChanged']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{
channel: keys.realtimeEventChannel,
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: false, rankings: false },
}),
},
]);
});
@@ -64,8 +72,9 @@ describe('TournamentStore source revision', () => {
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.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();
@@ -88,9 +97,21 @@ describe('TournamentStore source revision', () => {
nextAt: '2026-08-17T00:00:00.000Z',
});
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
expect(redis.events).toEqual([
'commit:1',
'publish:1',
'publish:tournamentProjectionChanged',
'publish:tournamentChanged',
]);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{
channel: keys.realtimeEventChannel,
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: true, rankings: false },
}),
},
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
@@ -101,14 +122,12 @@ describe('TournamentStore source revision', () => {
const revisions = await Promise.all(
Array.from({ length: 50 }, (_, index) =>
store.setMatches([
{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 },
])
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);
expect(redis.published).toHaveLength(100);
});
});
@@ -80,6 +80,9 @@ const shiftTournamentClock = async (
const stateKey = `sammo:${profileName}:tournament:state`;
const sourceKeys = {
stateKey,
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`,
realtimeEventChannel: buildGameEventChannel(profileName),
@@ -142,6 +142,9 @@ const reprojectTournamentClock = async (
const stateKey = `sammo:${profileName}:tournament:state`;
const sourceKeys = {
stateKey,
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`,
realtimeEventChannel: buildGameEventChannel(profileName),
@@ -1,10 +1,4 @@
import {
asRecord,
buildGameEventChannel,
LiteHashDRBG,
RandUtil,
writeTournamentProjection,
} from '@sammo-ts/common';
import { asRecord, buildGameEventChannel, LiteHashDRBG, RandUtil, writeTournamentProjection } from '@sammo-ts/common';
import type { RedisConnector } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -76,9 +70,9 @@ export const createTournamentAutoStartHandler = (options: {
}): TurnCalendarHandler => {
const keys = {
stateKey: `sammo:${options.profileName}:tournament:state`,
participants: `sammo:${options.profileName}:tournament:participants`,
matches: `sammo:${options.profileName}:tournament:matches`,
betting: `sammo:${options.profileName}:tournament:betting`,
participantsKey: `sammo:${options.profileName}:tournament:participants`,
matchesKey: `sammo:${options.profileName}:tournament:matches`,
bettingKey: `sammo:${options.profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(options.profileName),
@@ -149,9 +143,9 @@ export const createTournamentAutoStartHandler = (options: {
lastErrorAt: undefined,
};
await writeTournamentProjection(redis, keys, [
{ key: keys.participants, value: [] },
{ key: keys.matches, value: [] },
{ key: keys.betting, value: [] },
{ key: keys.participantsKey, value: [] },
{ key: keys.matchesKey, value: [] },
{ key: keys.bettingKey, value: [] },
{ key: keys.stateKey, value: nextState },
]);
+178 -2
View File
@@ -195,6 +195,8 @@ const installFixture = async (
tournamentStage?: number;
joinedGroupId?: number;
emptyFinalGroups?: boolean;
realtimeState?: { tournamentStage: number; totalAmount: number };
onOperation?: (operation: string, headers: Record<string, string>) => void;
} = {}
) => {
let joined = false;
@@ -213,13 +215,17 @@ const installFixture = async (
});
await page.route(gameTrpcRoute, async (route) => {
const results = operationNames(route).map((operation) => {
options.onOperation?.(operation, route.request().headers());
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
if (operation === 'tournament.getSnapshot') {
const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0);
const tournamentStage =
options.realtimeState?.tournamentStage ??
options.tournamentStage ??
(options.applicationOpen ? 1 : 0);
const joinedGroupId = options.joinedGroupId ?? 0;
return response({
state: {
@@ -272,7 +278,7 @@ const installFixture = async (
participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10])
),
myTotals: { 1: 120, 2: 40 },
totalAmount: 2800,
totalAmount: options.realtimeState?.totalAmount ?? 2800,
myAmount: 160,
});
}
@@ -318,6 +324,44 @@ const installFixture = async (
return { placedBets };
};
const installFakeEventSource = async (page: Page) => {
await page.addInitScript(() => {
class FakeEventSource extends EventTarget {
static instances: FakeEventSource[] = [];
readonly url: string;
closed = false;
constructor(url: string | URL) {
super();
this.url = String(url);
FakeEventSource.instances.push(this);
queueMicrotask(() => {
if (!this.closed) this.dispatchEvent(new Event('open'));
});
}
close() {
this.closed = true;
}
emit(type: string, payload: unknown) {
if (this.closed) return;
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
}
}
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
Object.assign(window, {
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length,
__tournamentEventSourceUrls: () =>
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url),
__emitTournamentEvent: (type: string, payload: unknown) => {
for (const source of FakeEventSource.instances) source.emit(type, payload);
},
});
});
};
const openTournament = async (page: Page) => {
await installFixture(page);
await page.goto('tournament');
@@ -732,6 +776,138 @@ test('tournament and betting pages expose same-row navigation tabs beside close'
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('betting realtime refresh is shared across tabs and preserves local interaction state', async ({
page,
context,
}) => {
test.setTimeout(40_000);
const follower = await context.newPage();
await Promise.all([
page.setViewportSize({ width: 390, height: 844 }),
follower.setViewportSize({ width: 390, height: 844 }),
]);
const state = { tournamentStage: 6, totalAmount: 2800 };
const operations: Array<{ operation: string; grant: string | undefined }> = [];
const fixtureOptions = {
realtimeState: state,
onOperation: (operation: string, headers: Record<string, string>) => {
operations.push({ operation, grant: headers['x-sammo-realtime-access-grant'] });
},
};
await Promise.all([installFakeEventSource(page), installFakeEventSource(follower)]);
await Promise.all([installFixture(page, fixtureOptions), installFixture(follower, fixtureOptions)]);
await Promise.all([page.goto('betting'), follower.goto('betting')]);
await Promise.all([
expect(page.getByRole('tab', { name: '전력전' })).toBeVisible(),
expect(follower.getByRole('tab', { name: '전력전' })).toBeVisible(),
]);
await follower.getByRole('tab', { name: '통솔전' }).click();
await follower.getByRole('button', { name: '관우에게 베팅하기' }).click();
await follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액').selectOption('50');
await expect
.poll(async () => {
const counts = await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate(() =>
(
window as unknown as {
__tournamentEventSourceCount: () => number;
}
).__tournamentEventSourceCount()
)
)
);
return counts.reduce((sum, count) => sum + count, 0);
})
.toBe(1);
const activeSourceUrls = (
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate(() =>
(
window as unknown as {
__tournamentEventSourceUrls: () => string[];
}
).__tournamentEventSourceUrls()
)
)
)
).flat();
expect(activeSourceUrls).toHaveLength(1);
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament');
const before = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
};
state.totalAmount = 3333;
const payload = {
type: 'tournamentViewInvalidated',
refreshGrant: 'opaque-e2e-grant',
invalidation: { snapshot: false, betting: true, rankings: false },
};
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate((event) => {
(
window as unknown as {
__emitTournamentEvent: (type: string, payload: unknown) => void;
}
).__emitTournamentEvent('tournamentViewInvalidated', event);
}, payload)
)
);
await expect
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length)
.toBe(before.betting + 1);
await expect(page.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
expect(operations.filter(({ operation }) => operation === 'tournament.getSnapshot')).toHaveLength(before.snapshot);
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(before.rankings);
expect(
operations.filter(
({ operation, grant }) => operation === 'tournament.getBettingSummary' && grant === 'opaque-e2e-grant'
)
).toHaveLength(1);
const recoveryBefore = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
};
for (const visibilityState of ['hidden', 'visible'] as const) {
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate((nextVisibilityState) => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: nextVisibilityState,
});
document.dispatchEvent(new Event('visibilitychange'));
}, visibilityState)
)
);
}
await expect
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length)
.toBe(recoveryBefore.snapshot + 1);
expect(operations.filter(({ operation }) => operation === 'tournament.getBettingSummary')).toHaveLength(
recoveryBefore.betting + 1
);
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(
recoveryBefore.rankings + 1
);
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
});
test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => {
const baseURL = testInfo.project.use.baseURL;
expect(typeof baseURL).toBe('string');
@@ -0,0 +1,301 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
import { structurallyShare } from '../utils/structuralShare';
import { trpc } from '../utils/trpc';
import { useSessionStore } from './session';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
type BettingSummary = Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>>;
type Rankings = Awaited<ReturnType<typeof trpc.tournament.getRankings.query>>;
type TournamentPatch = {
snapshot?: Snapshot;
betting?: BettingSummary;
rankings?: Rankings;
};
type TournamentTabMessage =
{ kind: 'patch'; patch: TournamentPatch } | { kind: 'status'; status: 'idle' | 'connected' };
const EMPTY_INVALIDATION = (): TournamentViewInvalidation => ({ snapshot: false, betting: false, rankings: false });
const FULL_INVALIDATION = (): TournamentViewInvalidation => ({ snapshot: true, betting: true, rankings: true });
const hasInvalidation = (value: TournamentViewInvalidation): boolean =>
value.snapshot || value.betting || value.rankings;
const mergeInvalidation = (
left: TournamentViewInvalidation,
right: TournamentViewInvalidation
): TournamentViewInvalidation => ({
snapshot: left.snapshot || right.snapshot,
betting: left.betting || right.betting,
rankings: left.rankings || right.rankings,
});
const resolveErrorMessage = (value: unknown): string => (value instanceof Error ? value.message : String(value));
export const useTournamentPagesStore = defineStore('tournamentPages', () => {
const session = useSessionStore();
const snapshot = ref<Snapshot | null>(null);
const betting = ref<BettingSummary | null>(null);
const rankings = ref<Rankings>([]);
const loading = ref(false);
const refreshing = ref(false);
const error = ref<string | null>(null);
const realtimeStatus = ref<'idle' | 'connected'>('idle');
let activeConsumers = 0;
let realtimeSource: EventSource | null = null;
let realtimeToken: string | null = null;
let realtimeCoordinator: BroadcastTabCoordinator<TournamentTabMessage> | null = null;
let realtimeCoordinatorScope: string | null = null;
let visibilityListenerInstalled = false;
let needsRecovery = false;
let pendingInvalidation = EMPTY_INVALIDATION();
let pendingRefreshGrant: string | null = null;
const applyPatch = (patch: TournamentPatch): void => {
if (patch.snapshot !== undefined) {
snapshot.value =
snapshot.value === null ? patch.snapshot : structurallyShare(snapshot.value, patch.snapshot);
}
if (patch.betting !== undefined) {
betting.value = betting.value === null ? patch.betting : structurallyShare(betting.value, patch.betting);
}
if (patch.rankings !== undefined) {
rankings.value = structurallyShare(rankings.value, patch.rankings);
}
};
const refreshProjection = async (
invalidation: TournamentViewInvalidation,
refreshGrant?: string | null,
foreground = false
): Promise<TournamentPatch> => {
if (!hasInvalidation(invalidation)) return {};
if (foreground) {
loading.value = true;
error.value = null;
} else {
refreshing.value = true;
}
const queryOptions = createRealtimeRequestOptions(refreshGrant);
try {
const [nextSnapshot, nextBetting, nextRankings] = await Promise.all([
invalidation.snapshot ? trpc.tournament.getSnapshot.query(undefined, queryOptions) : undefined,
invalidation.betting ? trpc.tournament.getBettingSummary.query(undefined, queryOptions) : undefined,
invalidation.rankings ? trpc.tournament.getRankings.query(undefined, queryOptions) : undefined,
]);
const patch: TournamentPatch = {};
if (nextSnapshot !== undefined) patch.snapshot = nextSnapshot;
if (nextBetting !== undefined) patch.betting = nextBetting;
if (nextRankings !== undefined) patch.rankings = nextRankings;
applyPatch(patch);
return patch;
} catch (value) {
if (foreground) error.value = resolveErrorMessage(value);
return {};
} finally {
if (foreground) loading.value = false;
else refreshing.value = false;
}
};
const backgroundRefreshQueue = createRateLimitedRefreshQueue(
async () => {
const invalidation = pendingInvalidation;
const refreshGrant = pendingRefreshGrant;
pendingInvalidation = EMPTY_INVALIDATION();
pendingRefreshGrant = null;
const patch = await refreshProjection(invalidation, refreshGrant);
if (Object.keys(patch).length > 0) {
realtimeCoordinator?.postFromLeader({ kind: 'patch', patch });
}
},
{ minIntervalMs: 5_000 }
);
const requestBackgroundRefresh = (invalidation: TournamentViewInvalidation, refreshGrant?: string | null): void => {
pendingInvalidation = mergeInvalidation(pendingInvalidation, invalidation);
if (refreshGrant) pendingRefreshGrant = refreshGrant;
backgroundRefreshQueue.request();
};
const loadTournamentPage = (): Promise<TournamentPatch> =>
refreshProjection({ snapshot: true, betting: true, rankings: false }, null, true);
const loadBettingPage = (): Promise<TournamentPatch> => refreshProjection(FULL_INVALIDATION(), null, true);
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
const ensureAccessToken = async (): Promise<string | null> => {
if (!session.gameToken) return null;
if (isAccessToken(session.gameToken)) return session.gameToken;
if (!(await session.exchangeGatewayToken())) return null;
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
};
const buildRealtimeUrl = (token: string): string => {
const base = import.meta.env.VITE_GAME_SSE_URL ?? '/events';
const url = new URL(base, window.location.origin);
url.searchParams.set('token', token);
url.searchParams.set('scope', 'tournament');
return url.toString();
};
const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => {
if (!raw.data || typeof raw.data !== 'string') return null;
try {
const parsed = JSON.parse(raw.data) as PublicRealtimeEvent;
return parsed && typeof parsed === 'object' && typeof parsed.type === 'string' ? parsed : null;
} catch {
return null;
}
};
const closeRealtimeSource = (): void => {
realtimeSource?.close();
realtimeSource = null;
realtimeToken = null;
};
const isRealtimeParticipant = (): boolean =>
activeConsumers > 0 && document.visibilityState !== 'hidden' && session.isReady && session.hasGeneral;
const closeRealtimeCoordinator = (): void => {
const coordinator = realtimeCoordinator;
realtimeCoordinator = null;
realtimeCoordinatorScope = null;
coordinator?.stop();
closeRealtimeSource();
};
const connectRealtime = async (): Promise<void> => {
if (
typeof window === 'undefined' ||
!isRealtimeParticipant() ||
(realtimeCoordinator !== null && !realtimeCoordinator.isLeader())
) {
return;
}
const token = await ensureAccessToken();
if (!token || !isRealtimeParticipant()) return;
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
if (realtimeSource && realtimeToken === token) return;
closeRealtimeSource();
realtimeToken = token;
const source = new EventSource(buildRealtimeUrl(token));
realtimeSource = source;
source.addEventListener('open', () => {
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
if (needsRecovery) {
needsRecovery = false;
backgroundRefreshQueue.beginCooldown();
void refreshProjection(FULL_INVALIDATION()).then((patch) => {
if (Object.keys(patch).length > 0) {
realtimeCoordinator?.postFromLeader({ kind: 'patch', patch });
}
});
}
});
source.addEventListener('error', () => {
needsRecovery = true;
realtimeStatus.value = 'idle';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
});
source.addEventListener('tournamentViewInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'tournamentViewInvalidated') return;
requestBackgroundRefresh(payload.invalidation, payload.refreshGrant);
});
source.addEventListener('ping', () => {
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
});
};
const reconcileRealtimeCoordinator = (): void => {
if (typeof window === 'undefined') return;
if (!isRealtimeParticipant()) {
closeRealtimeCoordinator();
return;
}
if (typeof BroadcastChannel === 'undefined') {
void connectRealtime();
return;
}
const profile = session.profile ?? 'game';
const account = session.user?.id ?? 'general';
const scope = `${encodeURIComponent(profile)}:${encodeURIComponent(account)}`;
if (realtimeCoordinator && realtimeCoordinatorScope === scope) return;
closeRealtimeCoordinator();
realtimeCoordinatorScope = scope;
realtimeCoordinator = createBroadcastTabCoordinator<TournamentTabMessage>(`sammo:tournament-pages:${scope}`, {
onLeadershipChange: (leader) => {
if (leader) void connectRealtime();
else closeRealtimeSource();
},
onPayload: (message) => {
if (!isRealtimeParticipant()) return;
if (message.kind === 'patch') applyPatch(message.patch);
else realtimeStatus.value = message.status;
},
});
realtimeCoordinator.start();
};
const handleVisibilityChange = (): void => {
if (activeConsumers === 0) return;
if (document.visibilityState === 'hidden') {
backgroundRefreshQueue.cancelPending();
pendingInvalidation = EMPTY_INVALIDATION();
pendingRefreshGrant = null;
closeRealtimeCoordinator();
realtimeStatus.value = 'idle';
return;
}
backgroundRefreshQueue.beginCooldown();
needsRecovery = true;
reconcileRealtimeCoordinator();
};
const startRealtime = (): void => {
if (typeof window === 'undefined') return;
activeConsumers += 1;
if (activeConsumers > 1) return;
backgroundRefreshQueue.beginCooldown();
document.addEventListener('visibilitychange', handleVisibilityChange);
visibilityListenerInstalled = true;
reconcileRealtimeCoordinator();
};
const stopRealtime = (): void => {
activeConsumers = Math.max(0, activeConsumers - 1);
if (activeConsumers > 0) return;
backgroundRefreshQueue.cancelPending();
pendingInvalidation = EMPTY_INVALIDATION();
pendingRefreshGrant = null;
closeRealtimeCoordinator();
if (visibilityListenerInstalled) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
visibilityListenerInstalled = false;
}
realtimeStatus.value = 'idle';
};
watch(
() => [session.isReady, session.hasGeneral, session.gameToken, session.profile, session.user?.id],
() => reconcileRealtimeCoordinator()
);
return {
snapshot,
betting,
rankings,
loading,
refreshing,
error,
realtimeStatus,
loadTournamentPage,
loadBettingPage,
startRealtime,
stopRealtime,
};
});
+11 -24
View File
@@ -1,20 +1,17 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { useTournamentPagesStore } from '../stores/tournamentPages';
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
const snapshot = ref<Snapshot | null>(null);
const summary = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>> | null>(null);
const rankings = ref<Awaited<ReturnType<typeof trpc.tournament.getRankings.query>>>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting: summary, rankings, loading, error } = storeToRefs(tournamentPages);
const amounts = ref<Record<number, number>>({});
const selectedTarget = ref<TournamentBracketSlot | null>(null);
const betDialog = ref<HTMLDialogElement | null>(null);
@@ -39,22 +36,12 @@ const stageNames = [
];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
loading.value = true;
error.value = null;
try {
[snapshot.value, summary.value, rankings.value] = await Promise.all([
trpc.tournament.getSnapshot.query(),
trpc.tournament.getBettingSummary.query(),
trpc.tournament.getRankings.query(),
]);
} catch (value) {
error.value = errorText(value);
} finally {
loading.value = false;
}
};
onMounted(() => void load());
const load = () => tournamentPages.loadBettingPage();
onMounted(() => {
tournamentPages.startRealtime();
void load();
});
onUnmounted(() => tournamentPages.stopRealtime());
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
const myAmount = computed(() => summary.value?.myAmount ?? 0);
+13 -16
View File
@@ -1,21 +1,20 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentGroupCard from '../components/tournament/TournamentGroupCard.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { useTournamentPagesStore } from '../stores/tournamentPages';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
const snapshot = ref<Snapshot | null>(null);
const betting = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>> | null>(null);
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting, loading, error } = storeToRefs(tournamentPages);
type Snapshot = NonNullable<typeof snapshot.value>;
const myGeneralId = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
@@ -27,27 +26,25 @@ const typeStatNames = ['종합', '통솔', '무력', '지력'];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
loading.value = true;
error.value = null;
try {
const [nextSnapshot, nextBetting, me, admin] = await Promise.all([
trpc.tournament.getSnapshot.query(),
trpc.tournament.getBettingSummary.query(),
const [, me, admin] = await Promise.all([
tournamentPages.loadTournamentPage(),
trpc.general.me.query(),
trpc.tournament.getAdminStatus.query().catch(() => null),
]);
snapshot.value = nextSnapshot;
betting.value = nextBetting;
myGeneralId.value = me?.general?.id ?? 0;
adminEnabled.value = !!admin?.ok;
} catch (value) {
error.value = errorText(value);
} finally {
loading.value = false;
}
};
onMounted(() => void load());
onMounted(() => {
tournamentPages.startRealtime();
void load();
});
onUnmounted(() => tournamentPages.stopRealtime());
const participantsById = computed(
() => new Map((snapshot.value?.participants ?? []).map((participant) => [participant.id, participant]))
+18 -4
View File
@@ -277,7 +277,14 @@ revision 증가를 같은 Lua script로 수행한다. source revision은 참가
`state.stage`를 비교해 실제 단계가 바뀔 때만 결정한다. commit 뒤
`tournamentChanged`를 공용 game event channel에 best-effort publish하며, API는 이를
식별자·revision 없는 public `tournament: true` invalidation으로 바꾼다. 참가 등록,
대진 결과, 같은 stage 안의 phase/timer/정산 flag write는 300 viewer를 깨우지 않는다.
대진 결과, 같은 stage 안의 phase/timer/정산 flag write는 메인 viewer를 깨우지 않는다.
토너먼트/베팅 페이지군은 같은 commit에서 별도 `tournamentProjectionChanged`를 받는다.
public payload는 `snapshot/betting/rankings` boolean만 남기고 `/events?scope=tournament`
연결한 전용 구독에만 전달한다. 따라서 메인 구독자 수만큼 grant를 만들거나 페이지 조회를
실행하지 않는다. `snapshot`은 state/participant/match write, `betting`은 bet write와 stage
transition, PostgreSQL-backed `rankings`는 선행 match-result input event와 reward DB
persistence가 모두 완료된 `rewardSettled` write에만 선택한다.
장기 durability 요구가 생기면 tournament state 자체를 PostgreSQL 소유로 옮기는 별도
migration으로 다룬다.
@@ -357,9 +364,8 @@ test가 완료되어 post-deploy one-off로 안전하게 활성화할 수 있다
meta/head 누락, DB/Redis 오류에는 shared cache를 완전히 우회해 full compute한다.
- 개인 `spyList`, `shownByGeneralList`, `myCity`, `myNation`은 request에서 계속 조합한다.
- tournament는 API store, 월 자동 개막과 runtime clock shift 모두 payload와 profile source
revision을 같은 Lua invocation으로 갱신한다. stage transition만 main realtime channel에
best-effort publish하고, 같은 stage의 phase/participant/match/bet write는 source revision만
진행한다.
revision을 같은 Lua invocation으로 갱신한다. stage transition만 main realtime event를
발행하고, 모든 write는 토너먼트 페이지군용 slice invalidation을 별도로 발행한다.
- records는 기존 `lastGeneralRecordId`/`lastWorldHistoryId` 증분 조회를 유지하되 해당
domain이 선택되지 않으면 query하지 않는다.
@@ -388,6 +394,14 @@ patch를 만들고, 같은 profile/account의 follower 탭은 BroadcastChannel p
갱신된다. Redis/API 오류에는 현재 stage를 거짓 0으로 덮지 않고 다음 event, 사용자
`갱 신`, visible 복귀 snapshot으로 복구한다.
토너먼트/베팅 화면은 별도 `tournamentPages` store가 snapshot, betting summary, rankings를
공유한다. 같은 profile/account의 visible 탭 중 leader 하나만 tournament-scope SSE와 선택된
tRPC query를 소유하고, 5초 burst를 union한 뒤 structural-sharing patch를 follower에 보낸다.
자동 갱신 중 기존 데이터, 모바일 랭킹 탭, 베팅 dialog/input은 유지한다. SSE reconnect와
visible 복귀는 leader가 fresh 3-slice snapshot 한 번을 읽어 pub/sub gap을 복구한다. 서버가
증명한 `tournament.getSnapshot`만 접속 점수를 더하지 않지만 기존 hard-limit gate는 그대로
검사한다.
## 구현 단계와 commit 경계
### Phase A: 저위험 read 절감
+27 -2
View File
@@ -223,6 +223,26 @@ export interface TournamentChangedEvent {
type: 'tournamentChanged';
}
/** Redis-owned slices changed after the atomic tournament projection commit. */
export interface TournamentProjectionChangedEvent {
type: 'tournamentProjectionChanged';
invalidation: TournamentViewInvalidation;
}
/** Browser-safe tournament page-family refresh selection. */
export interface TournamentViewInvalidation {
snapshot: boolean;
betting: boolean;
rankings: boolean;
}
export interface TournamentViewInvalidatedEvent {
type: 'tournamentViewInvalidated';
invalidation: TournamentViewInvalidation;
/** Opaque, short-lived proof that the server initiated this refresh. */
refreshGrant: string;
}
export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation;
@@ -239,7 +259,12 @@ export interface MessagesInvalidatedEvent {
export const REALTIME_ACCESS_GRANT_HEADER = 'x-sammo-realtime-access-grant';
/** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent | TournamentViewInvalidatedEvent;
export type RealtimeEvent =
TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent | TournamentChangedEvent;
| TurnCompletedEvent
| ReadModelChangedEvent
| MessageCreatedEvent
| MessagesChangedEvent
| TournamentChangedEvent
| TournamentProjectionChangedEvent;
@@ -1,5 +1,8 @@
export interface TournamentSourceKeys {
stateKey: string;
participantsKey: string;
matchesKey: string;
bettingKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
@@ -27,24 +30,29 @@ if current then
end
end
local stage_changed = false
local rankings_changed = false
for index = 1, #KEYS - 1 do
local next_ok, next_value = pcall(cjson.decode, ARGV[index])
if next_ok and type(next_value) == 'table' and next_value['stage'] ~= nil then
local previous = redis.call('GET', KEYS[index])
local previous_stage = nil
local previous_value = nil
if previous then
local previous_ok, previous_value = pcall(cjson.decode, previous)
local previous_ok
previous_ok, previous_value = pcall(cjson.decode, previous)
if previous_ok and type(previous_value) == 'table' then
previous_stage = previous_value['stage']
end
end
local next_stage = next_value['stage']
stage_changed = (not previous) or previous_stage ~= next_stage
local previous_reward_settled = previous_value and previous_value['rewardSettled'] or false
rankings_changed = next_value['rewardSettled'] == true and previous_reward_settled ~= true
end
redis.call('SET', KEYS[index], ARGV[index])
end
local revision = redis.call('INCR', revision_key)
return tostring(revision) .. ':' .. (stage_changed and '1' or '0')
return tostring(revision) .. ':' .. (stage_changed and '1' or '0') .. ':' .. (rankings_changed and '1' or '0')
`;
export const parseTournamentSourceRevision = (value: unknown): string | null => {
@@ -71,15 +79,27 @@ export const writeTournamentProjection = async (
}
const writesState = writes.some(({ key }) => key === keys.stateKey);
const writesSnapshot = writes.some(
({ key }) => key === keys.stateKey || key === keys.participantsKey || key === keys.matchesKey
);
const writesBetting = writes.some(({ key }) => key === keys.bettingKey);
const writesSettledRankings = writes.some(
({ key, value }) =>
key === keys.stateKey &&
typeof value === 'object' &&
value !== null &&
(value as { rewardSettled?: unknown }).rewardSettled === true
);
const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, {
keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey],
arguments: writes.map(({ value }) => JSON.stringify(value)),
});
const scriptResult = typeof result === 'string' ? /^(\d+):([01])$/u.exec(result) : null;
const scriptResult = typeof result === 'string' ? /^(\d+):([01])(?::([01]))?$/u.exec(result) : null;
const sourceRevision = parseTournamentSourceRevision(scriptResult?.[1] ?? result);
// Plain revision results remain accepted for rolling deployments and small
// Redis fakes; only the current Lua contract can suppress same-stage writes.
const stageChanged = writesState && (scriptResult ? scriptResult[2] === '1' : true);
const rankingsChanged = scriptResult?.[3] === undefined ? writesSettledRankings : scriptResult[3] === '1';
if (sourceRevision === null) {
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
}
@@ -90,6 +110,21 @@ export const writeTournamentProjection = async (
} catch {
// Payload and revision are committed; publication remains best effort.
}
try {
await redis.publish(
keys.realtimeEventChannel,
JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: {
snapshot: writesSnapshot,
betting: writesBetting || stageChanged,
rankings: rankingsChanged,
},
})
);
} catch {
// Tournament-page wake-up is a best-effort projection signal.
}
if (stageChanged) {
try {
await redis.publish(keys.realtimeEventChannel, JSON.stringify({ type: 'tournamentChanged' }));
@@ -2,6 +2,16 @@ import { describe, expect, it } from 'vitest';
import { writeTournamentProjection } from '../src/tournament/sourceRevision.js';
const keys = {
stateKey: 'state',
participantsKey: 'participants',
matchesKey: 'matches',
bettingKey: 'betting',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
};
describe('tournament source revision', () => {
it('passes every payload and one profile revision key to a single atomic script', async () => {
const calls: Array<{ keys: string[]; arguments: string[] }> = [];
@@ -18,19 +28,10 @@ describe('tournament source revision', () => {
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: { stage: 1 } },
{ key: 'matches', value: [] },
]
)
writeTournamentProjection(redis, keys, [
{ key: 'state', value: { stage: 1 } },
{ key: 'matches', value: [] },
])
).resolves.toBe('7');
expect(calls).toEqual([
@@ -41,38 +42,25 @@ describe('tournament source revision', () => {
]);
expect(published).toEqual([
{ channel: 'changed', message: JSON.stringify({ sourceRevision: '7' }) },
{
channel: 'realtime',
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: true, rankings: false },
}),
},
{ channel: 'realtime', message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
it('rejects empty or duplicate writes before evaluating Redis', async () => {
const redis = { eval: async () => '1' };
await expect(writeTournamentProjection(redis, keys, [])).rejects.toThrow('at least one');
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[]
)
).rejects.toThrow('at least one');
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: 1 },
{ key: 'state', value: 2 },
]
)
writeTournamentProjection(redis, keys, [
{ key: 'state', value: 1 },
{ key: 'state', value: 2 },
])
).rejects.toThrow('unique');
});
@@ -86,18 +74,9 @@ describe('tournament source revision', () => {
},
};
await writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'participants', value: [{ id: 7 }] }]
);
await writeTournamentProjection(redis, keys, [{ key: 'participants', value: [{ id: 7 }] }]);
expect(published).toEqual(['changed']);
expect(published).toEqual(['changed', 'realtime']);
});
it('does not wake the main dashboard when tournament state keeps the same stage', async () => {
@@ -111,18 +90,9 @@ describe('tournament source revision', () => {
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'state', value: { stage: 1, phase: 2 } }]
)
writeTournamentProjection(redis, keys, [{ key: 'state', value: { stage: 1, phase: 2 } }])
).resolves.toBe('10');
expect(published).toEqual(['changed']);
expect(published).toEqual(['changed', 'realtime']);
});
it('keeps both post-commit wake-up channels independently best effort', async () => {
@@ -136,18 +106,55 @@ describe('tournament source revision', () => {
},
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'state', value: { stage: 1 } }]
)
).resolves.toBe('9');
expect(published).toEqual(['changed', 'realtime']);
await expect(writeTournamentProjection(redis, keys, [{ key: 'state', value: { stage: 1 } }])).resolves.toBe(
'9'
);
expect(published).toEqual(['changed', 'realtime', 'realtime']);
});
it('selects only the betting slice for a same-stage bet write', async () => {
const published: Array<{ channel: string; message: string }> = [];
const redis = {
eval: async () => '11:0',
publish: async (channel: string, message: string) => {
published.push({ channel, message });
return 1;
},
};
await writeTournamentProjection(redis, keys, [{ key: 'betting', value: [{ targetId: 3, amount: 10 }] }]);
expect(published.at(-1)).toEqual({
channel: 'realtime',
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: false, betting: true, rankings: false },
}),
});
expect(published.some(({ message }) => message.includes('tournamentChanged'))).toBe(false);
});
it('refreshes rankings after reward persistence without waking the main dashboard', async () => {
const published: Array<{ channel: string; message: string }> = [];
const redis = {
eval: async () => '12:0',
publish: async (channel: string, message: string) => {
published.push({ channel, message });
return 1;
},
};
await writeTournamentProjection(redis, keys, [
{ key: 'state', value: { stage: 0, rewardSettled: true, bettingSettled: false } },
]);
expect(published.at(-1)).toEqual({
channel: 'realtime',
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: false, rankings: true },
}),
});
expect(published.some(({ message }) => message.includes('tournamentChanged'))).toBe(false);
});
});