merge: 최신 main을 브라우저 전투 시뮬레이터 작업에 통합
This commit is contained in:
@@ -51,6 +51,8 @@ export interface RealtimeReadModelInvalidation {
|
||||
reservedTurns: boolean;
|
||||
records: boolean;
|
||||
frontStatus: boolean;
|
||||
/** Shared tournament stage shown by the main dashboard changed. */
|
||||
tournament: boolean;
|
||||
}
|
||||
|
||||
export interface RealtimeViewerIdentity {
|
||||
@@ -69,6 +71,7 @@ export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInv
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
});
|
||||
|
||||
export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
|
||||
@@ -81,6 +84,7 @@ export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInva
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: true,
|
||||
tournament: true,
|
||||
});
|
||||
|
||||
export const mergeRealtimeReadModelInvalidations = (
|
||||
@@ -96,6 +100,7 @@ export const mergeRealtimeReadModelInvalidations = (
|
||||
reservedTurns: left.reservedTurns || right.reservedTurns,
|
||||
records: left.records || right.records,
|
||||
frontStatus: left.frontStatus || right.frontStatus,
|
||||
tournament: left.tournament || right.tournament,
|
||||
});
|
||||
|
||||
export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean =>
|
||||
@@ -141,6 +146,7 @@ export const resolveRealtimeReadModelInvalidation = (
|
||||
frontStatusGeneralChanged ||
|
||||
ownFrontStatusNationChanged ||
|
||||
ownFrontStatusActorChanged,
|
||||
tournament: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -212,6 +218,11 @@ export interface MessagesChangedEvent {
|
||||
mailboxes: number[];
|
||||
}
|
||||
|
||||
/** Redis-owned tournament stage changed after its atomic source revision commit. */
|
||||
export interface TournamentChangedEvent {
|
||||
type: 'tournamentChanged';
|
||||
}
|
||||
|
||||
export interface ReadModelInvalidatedEvent {
|
||||
type: 'readModelInvalidated';
|
||||
invalidation: RealtimeReadModelInvalidation;
|
||||
@@ -224,4 +235,9 @@ export interface MessagesInvalidatedEvent {
|
||||
/** Events safe to expose to an authenticated browser over SSE. */
|
||||
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
|
||||
|
||||
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent;
|
||||
export type RealtimeEvent =
|
||||
| TurnCompletedEvent
|
||||
| ReadModelChangedEvent
|
||||
| MessageCreatedEvent
|
||||
| MessagesChangedEvent
|
||||
| TournamentChangedEvent;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asRecord } from '../util/parse.js';
|
||||
import { buildGameEventChannel } from '../realtime/keys.js';
|
||||
import { writeTournamentProjection } from './sourceRevision.js';
|
||||
|
||||
interface TournamentState {
|
||||
@@ -44,6 +45,7 @@ const buildTournamentKeys = (profileName: string) => ({
|
||||
bettingKey: `sammo:${profileName}:tournament:betting`,
|
||||
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||
realtimeEventChannel: buildGameEventChannel(profileName),
|
||||
});
|
||||
|
||||
const resolveTermSeconds = (tickSeconds: number): number => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface TournamentSourceKeys {
|
||||
stateKey: string;
|
||||
sourceRevisionKey: string;
|
||||
sourceRevisionChannel: string;
|
||||
realtimeEventChannel: string;
|
||||
}
|
||||
|
||||
export interface TournamentProjectionRedis {
|
||||
@@ -24,11 +26,25 @@ if current then
|
||||
return redis.error_reply('tournament source revision exhausted')
|
||||
end
|
||||
end
|
||||
local stage_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
|
||||
if previous then
|
||||
local 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
|
||||
end
|
||||
redis.call('SET', KEYS[index], ARGV[index])
|
||||
end
|
||||
local revision = redis.call('INCR', revision_key)
|
||||
return tostring(revision)
|
||||
return tostring(revision) .. ':' .. (stage_changed and '1' or '0')
|
||||
`;
|
||||
|
||||
export const parseTournamentSourceRevision = (value: unknown): string | null => {
|
||||
@@ -54,11 +70,16 @@ export const writeTournamentProjection = async (
|
||||
throw new Error('Tournament projection write keys must be unique.');
|
||||
}
|
||||
|
||||
const writesState = writes.some(({ key }) => key === keys.stateKey);
|
||||
const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, {
|
||||
keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey],
|
||||
arguments: writes.map(({ value }) => JSON.stringify(value)),
|
||||
});
|
||||
const sourceRevision = parseTournamentSourceRevision(result);
|
||||
const scriptResult = typeof result === 'string' ? /^(\d+):([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);
|
||||
if (sourceRevision === null) {
|
||||
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
|
||||
}
|
||||
@@ -69,6 +90,13 @@ export const writeTournamentProjection = async (
|
||||
} catch {
|
||||
// Payload and revision are committed; publication remains best effort.
|
||||
}
|
||||
if (stageChanged) {
|
||||
try {
|
||||
await redis.publish(keys.realtimeEventChannel, JSON.stringify({ type: 'tournamentChanged' }));
|
||||
} catch {
|
||||
// The source-revision wake-up and main SSE wake-up are independent best-effort fan-out.
|
||||
}
|
||||
}
|
||||
}
|
||||
return sourceRevision;
|
||||
};
|
||||
|
||||
@@ -5,14 +5,14 @@ import { writeTournamentProjection } from '../src/tournament/sourceRevision.js';
|
||||
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[] }> = [];
|
||||
const published: string[] = [];
|
||||
const published: Array<{ channel: string; message: string }> = [];
|
||||
const redis = {
|
||||
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
|
||||
calls.push(options);
|
||||
return '7';
|
||||
return '7:1';
|
||||
},
|
||||
publish: async (_channel: string, message: string) => {
|
||||
published.push(message);
|
||||
publish: async (channel: string, message: string) => {
|
||||
published.push({ channel, message });
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
@@ -20,7 +20,12 @@ describe('tournament source revision', () => {
|
||||
await expect(
|
||||
writeTournamentProjection(
|
||||
redis,
|
||||
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
|
||||
{
|
||||
stateKey: 'state',
|
||||
sourceRevisionKey: 'revision',
|
||||
sourceRevisionChannel: 'changed',
|
||||
realtimeEventChannel: 'realtime',
|
||||
},
|
||||
[
|
||||
{ key: 'state', value: { stage: 1 } },
|
||||
{ key: 'matches', value: [] },
|
||||
@@ -34,18 +39,35 @@ describe('tournament source revision', () => {
|
||||
arguments: [JSON.stringify({ stage: 1 }), '[]'],
|
||||
},
|
||||
]);
|
||||
expect(published).toEqual([JSON.stringify({ sourceRevision: '7' })]);
|
||||
expect(published).toEqual([
|
||||
{ channel: 'changed', message: JSON.stringify({ sourceRevision: '7' }) },
|
||||
{ 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, { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, [])
|
||||
writeTournamentProjection(
|
||||
redis,
|
||||
{
|
||||
stateKey: 'state',
|
||||
sourceRevisionKey: 'revision',
|
||||
sourceRevisionChannel: 'changed',
|
||||
realtimeEventChannel: 'realtime',
|
||||
},
|
||||
[]
|
||||
)
|
||||
).rejects.toThrow('at least one');
|
||||
await expect(
|
||||
writeTournamentProjection(
|
||||
redis,
|
||||
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
|
||||
{
|
||||
stateKey: 'state',
|
||||
sourceRevisionKey: 'revision',
|
||||
sourceRevisionChannel: 'changed',
|
||||
realtimeEventChannel: 'realtime',
|
||||
},
|
||||
[
|
||||
{ key: 'state', value: 1 },
|
||||
{ key: 'state', value: 2 },
|
||||
@@ -53,4 +75,79 @@ describe('tournament source revision', () => {
|
||||
)
|
||||
).rejects.toThrow('unique');
|
||||
});
|
||||
|
||||
it('does not wake the main dashboard for participant-only writes', async () => {
|
||||
const published: string[] = [];
|
||||
const redis = {
|
||||
eval: async () => '8',
|
||||
publish: async (channel: string) => {
|
||||
published.push(channel);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
await writeTournamentProjection(
|
||||
redis,
|
||||
{
|
||||
stateKey: 'state',
|
||||
sourceRevisionKey: 'revision',
|
||||
sourceRevisionChannel: 'changed',
|
||||
realtimeEventChannel: 'realtime',
|
||||
},
|
||||
[{ key: 'participants', value: [{ id: 7 }] }]
|
||||
);
|
||||
|
||||
expect(published).toEqual(['changed']);
|
||||
});
|
||||
|
||||
it('does not wake the main dashboard when tournament state keeps the same stage', async () => {
|
||||
const published: string[] = [];
|
||||
const redis = {
|
||||
eval: async () => '10:0',
|
||||
publish: async (channel: string) => {
|
||||
published.push(channel);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
writeTournamentProjection(
|
||||
redis,
|
||||
{
|
||||
stateKey: 'state',
|
||||
sourceRevisionKey: 'revision',
|
||||
sourceRevisionChannel: 'changed',
|
||||
realtimeEventChannel: 'realtime',
|
||||
},
|
||||
[{ key: 'state', value: { stage: 1, phase: 2 } }]
|
||||
)
|
||||
).resolves.toBe('10');
|
||||
expect(published).toEqual(['changed']);
|
||||
});
|
||||
|
||||
it('keeps both post-commit wake-up channels independently best effort', async () => {
|
||||
const published: string[] = [];
|
||||
const redis = {
|
||||
eval: async () => '9',
|
||||
publish: async (channel: string) => {
|
||||
published.push(channel);
|
||||
if (channel === 'changed') throw new Error('source subscriber unavailable');
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user