merge: 최신 main을 ETag 최적화 브랜치에 통합한다
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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 절감
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user