fix: 토너먼트 단계의 메인 실시간 갱신을 연결
토너먼트 state의 stage 전이만 공용 실시간 채널에 발행하고 공개 invalidation으로 변환한다. 메인 dashboard store가 단계 값을 소유해 상단 문구와 메뉴 강조를 단일/다중 탭에서 함께 갱신한다. Redis 통합 테스트와 production Chromium 회귀 검증을 추가한다.
This commit is contained in:
@@ -69,6 +69,24 @@ export const toPublicRealtimeEvent = (
|
||||
: null;
|
||||
}
|
||||
|
||||
if (event.type === 'tournamentChanged') {
|
||||
return {
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: false,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: false,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === 'turnCompleted' && !event.changes) {
|
||||
return {
|
||||
type: 'readModelInvalidated',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { buildGameEventChannel } from '@sammo-ts/common';
|
||||
|
||||
export interface TournamentKeys {
|
||||
stateKey: string;
|
||||
participantsKey: string;
|
||||
@@ -5,6 +7,7 @@ export interface TournamentKeys {
|
||||
bettingKey: string;
|
||||
sourceRevisionKey: string;
|
||||
sourceRevisionChannel: string;
|
||||
realtimeEventChannel: string;
|
||||
}
|
||||
|
||||
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
@@ -14,4 +17,5 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
bettingKey: `sammo:${profileName}:tournament:betting`,
|
||||
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||
realtimeEventChannel: buildGameEventChannel(profileName),
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('public realtime event privacy boundary', () => {
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(publicEvent);
|
||||
@@ -108,10 +109,33 @@ describe('public realtime event privacy boundary', () => {
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: true,
|
||||
tournament: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts tournament state changes to one global boolean invalidation', () => {
|
||||
const publicEvent = toPublicRealtimeEvent({ type: 'tournamentChanged' }, [viewer]);
|
||||
|
||||
expect(publicEvent).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: false,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: false,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId/u);
|
||||
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
|
||||
});
|
||||
|
||||
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'messageCreated',
|
||||
|
||||
@@ -42,6 +42,12 @@ describe('parseRealtimeEvent', () => {
|
||||
expect(parseRealtimeEvent('not-json')).toBeNull();
|
||||
expect(parseRealtimeEvent(JSON.stringify({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the minimal tournament state wake-up', () => {
|
||||
expect(parseRealtimeEvent(JSON.stringify({ type: 'tournamentChanged' }))).toEqual({
|
||||
type: 'tournamentChanged',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGameEventChannel', () => {
|
||||
|
||||
@@ -10,12 +10,27 @@ const integration = describe.skipIf(!process.env.REDIS_URL);
|
||||
|
||||
integration('TournamentStore Redis source revision', () => {
|
||||
let connector: RedisConnector;
|
||||
let subscriber: RedisConnector;
|
||||
const profile = `test:tournament-revision:${randomUUID()}`;
|
||||
const keys = buildTournamentKeys(profile);
|
||||
const sourceMessages: string[] = [];
|
||||
const realtimeMessages: string[] = [];
|
||||
|
||||
const waitForLength = async (values: readonly string[], length: number): Promise<void> => {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (values.length < length && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(values).toHaveLength(length);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
connector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
subscriber = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await connector.connect();
|
||||
await subscriber.connect();
|
||||
await subscriber.client.subscribe(keys.sourceRevisionChannel, (message) => sourceMessages.push(message));
|
||||
await subscriber.client.subscribe(keys.realtimeEventChannel, (message) => realtimeMessages.push(message));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -27,6 +42,11 @@ integration('TournamentStore Redis source revision', () => {
|
||||
keys.bettingKey,
|
||||
keys.sourceRevisionKey,
|
||||
]);
|
||||
if (subscriber) {
|
||||
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
|
||||
await subscriber.client.unsubscribe(keys.realtimeEventChannel);
|
||||
await subscriber.disconnect();
|
||||
}
|
||||
await connector.disconnect();
|
||||
});
|
||||
|
||||
@@ -44,4 +64,37 @@ integration('TournamentStore Redis source revision', () => {
|
||||
await expect(store.getSourceRevision()).resolves.toBe('20');
|
||||
await expect(store.getMatches()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('publishes the main wake-up only when the atomic state write changes stage', async () => {
|
||||
const store = new TournamentStore(connector.client, keys);
|
||||
const sourceBefore = sourceMessages.length;
|
||||
const realtimeBefore = realtimeMessages.length;
|
||||
const baseState = {
|
||||
stage: 1,
|
||||
phase: 0,
|
||||
type: 0 as const,
|
||||
auto: true,
|
||||
openYear: 185,
|
||||
openMonth: 2,
|
||||
termSeconds: 10,
|
||||
nextAt: '2026-08-17T00:00:00.000Z',
|
||||
};
|
||||
|
||||
await store.setState(baseState);
|
||||
await waitForLength(sourceMessages, sourceBefore + 1);
|
||||
await waitForLength(realtimeMessages, realtimeBefore + 1);
|
||||
|
||||
await store.setState({ ...baseState, phase: 1 });
|
||||
await waitForLength(sourceMessages, sourceBefore + 2);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
|
||||
|
||||
await store.setState({ ...baseState, stage: 2, phase: 0 });
|
||||
await waitForLength(sourceMessages, sourceBefore + 3);
|
||||
await waitForLength(realtimeMessages, realtimeBefore + 2);
|
||||
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
|
||||
JSON.stringify({ type: 'tournamentChanged' }),
|
||||
JSON.stringify({ type: 'tournamentChanged' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,8 @@ class AtomicMemoryRedis {
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<number> {
|
||||
this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`);
|
||||
const payload = JSON.parse(message) as { sourceRevision?: string; type?: string };
|
||||
this.events.push(`publish:${payload.sourceRevision ?? payload.type ?? 'unknown'}`);
|
||||
this.published.push({ channel, message });
|
||||
return 1;
|
||||
}
|
||||
@@ -71,6 +72,29 @@ describe('TournamentStore source revision', () => {
|
||||
expect(redis.published).toEqual([]);
|
||||
});
|
||||
|
||||
it('wakes the main realtime channel only for shared state writes', async () => {
|
||||
const redis = new AtomicMemoryRedis();
|
||||
const keys = buildTournamentKeys('che:default');
|
||||
const store = new TournamentStore(redis, keys);
|
||||
|
||||
await store.setState({
|
||||
stage: 1,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 185,
|
||||
openMonth: 2,
|
||||
termSeconds: 10,
|
||||
nextAt: '2026-08-17T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
|
||||
expect(redis.published).toEqual([
|
||||
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
|
||||
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes concurrent writes into monotonic per-profile revisions', async () => {
|
||||
const redis = new AtomicMemoryRedis();
|
||||
const store = new TournamentStore(redis, buildTournamentKeys('pwe:default'));
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { writeTournamentProjection, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
writeTournamentProjection,
|
||||
type TurnDaemonCommand,
|
||||
type TurnDaemonCommandResult,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
|
||||
|
||||
@@ -74,8 +79,10 @@ const shiftTournamentClock = async (
|
||||
): Promise<boolean> => {
|
||||
const stateKey = `sammo:${profileName}:tournament:state`;
|
||||
const sourceKeys = {
|
||||
stateKey,
|
||||
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||
realtimeEventChannel: buildGameEventChannel(profileName),
|
||||
};
|
||||
const lockKey = `${stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { asRecord, 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';
|
||||
@@ -69,12 +75,13 @@ export const createTournamentAutoStartHandler = (options: {
|
||||
now?: () => Date;
|
||||
}): TurnCalendarHandler => {
|
||||
const keys = {
|
||||
state: `sammo:${options.profileName}:tournament:state`,
|
||||
stateKey: `sammo:${options.profileName}:tournament:state`,
|
||||
participants: `sammo:${options.profileName}:tournament:participants`,
|
||||
matches: `sammo:${options.profileName}:tournament:matches`,
|
||||
betting: `sammo:${options.profileName}:tournament:betting`,
|
||||
sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`,
|
||||
sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`,
|
||||
realtimeEventChannel: buildGameEventChannel(options.profileName),
|
||||
};
|
||||
return {
|
||||
onMonthChanged: async (context) => {
|
||||
@@ -85,7 +92,7 @@ export const createTournamentAutoStartHandler = (options: {
|
||||
if (!world || !redis || config.tournamentTrig !== true) {
|
||||
return;
|
||||
}
|
||||
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.state));
|
||||
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.stateKey));
|
||||
if (previousState && previousState.stage > 0) {
|
||||
return;
|
||||
}
|
||||
@@ -145,7 +152,7 @@ export const createTournamentAutoStartHandler = (options: {
|
||||
{ key: keys.participants, value: [] },
|
||||
{ key: keys.matches, value: [] },
|
||||
{ key: keys.betting, value: [] },
|
||||
{ key: keys.state, value: nextState },
|
||||
{ key: keys.stateKey, value: nextState },
|
||||
]);
|
||||
|
||||
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0];
|
||||
|
||||
@@ -97,6 +97,7 @@ const readModelInvalidation = (
|
||||
reservedTurns: boolean;
|
||||
records: boolean;
|
||||
frontStatus: boolean;
|
||||
tournament: boolean;
|
||||
}>
|
||||
) => ({
|
||||
context: false,
|
||||
@@ -108,6 +109,7 @@ const readModelInvalidation = (
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -2300,6 +2302,22 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await waitForMain(page);
|
||||
await expect(page.locator('.general-title')).toContainText('메뉴검증장수');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
|
||||
|
||||
const operationsBeforeTournament = state.operations.length;
|
||||
state.stage = 1;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
|
||||
await expect
|
||||
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
|
||||
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const general = document.querySelector('[data-main-target="general"]');
|
||||
@@ -2352,6 +2370,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2420,6 +2439,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: true,
|
||||
tournament: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -2577,6 +2597,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -2751,6 +2772,23 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
|
||||
const followerPage = pages[followerIndex];
|
||||
if (!leaderPage || !followerPage) throw new Error('realtime leader election failed');
|
||||
|
||||
const operationsBeforeTournament = state.operations.length;
|
||||
state.stage = 1;
|
||||
await emitReadModelInvalidation(leaderPage, readModelInvalidation({ tournament: true }));
|
||||
await expect
|
||||
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
|
||||
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
|
||||
await Promise.all(
|
||||
pages.map((currentPage) =>
|
||||
expect(currentPage.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중')
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
pages.map((currentPage) =>
|
||||
expect(currentPage.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u)
|
||||
)
|
||||
);
|
||||
|
||||
const callsBeforeSharedRefresh = state.generalMeCalls;
|
||||
state.generalName = '탭공유갱신장수';
|
||||
await leaderPage.evaluate(() => {
|
||||
@@ -2767,6 +2805,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -2794,6 +2833,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
|
||||
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.query>>;
|
||||
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
|
||||
type DashboardReadModelPatch = {
|
||||
contextSnapshot?: GeneralContext;
|
||||
@@ -72,6 +73,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
generalRecords?: RecentRecord[];
|
||||
worldHistory?: RecentRecord[];
|
||||
frontStatus?: FrontStatus | null;
|
||||
tournamentStage?: number;
|
||||
};
|
||||
type DashboardTabMessage =
|
||||
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
|
||||
@@ -112,6 +114,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const generalRecords = ref<RecentRecord[]>([]);
|
||||
const worldHistory = ref<RecentRecord[]>([]);
|
||||
const frontStatus = ref<FrontStatus | null>(null);
|
||||
const tournamentStage = ref(0);
|
||||
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
|
||||
let lastGeneralRecordId = 0;
|
||||
let lastWorldHistoryId = 0;
|
||||
@@ -430,6 +433,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
} else if (patch.frontStatus !== undefined) {
|
||||
updateFrontStatus(patch.frontStatus);
|
||||
}
|
||||
if (patch.tournamentStage !== undefined) {
|
||||
tournamentStage.value = patch.tournamentStage;
|
||||
}
|
||||
if (patch.contextRevision !== undefined) {
|
||||
contextRevision = patch.contextRevision;
|
||||
contextSourceRevision = patch.contextSourceRevision ?? null;
|
||||
@@ -476,6 +482,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
patch.generalRecords = toRaw(generalRecords.value);
|
||||
patch.worldHistory = toRaw(worldHistory.value);
|
||||
patch.frontStatus = toRaw(frontStatus.value);
|
||||
patch.tournamentStage = tournamentStage.value;
|
||||
return patch;
|
||||
};
|
||||
|
||||
@@ -585,7 +592,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
frontStatusError.value = resolveErrorMessage(err);
|
||||
return null;
|
||||
});
|
||||
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] =
|
||||
const tournamentPromise = trpc.tournament.getState.query().catch(() => undefined);
|
||||
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus, tournamentState] =
|
||||
await Promise.all([
|
||||
layoutPromise,
|
||||
trpc.lobby.info.query(),
|
||||
@@ -595,6 +603,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
generalTurnsPromise,
|
||||
recordsPromise,
|
||||
frontStatusPromise,
|
||||
tournamentPromise,
|
||||
]);
|
||||
|
||||
general.value = structurallyShare(general.value, context.general);
|
||||
@@ -617,6 +626,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (nextFrontStatus) {
|
||||
updateFrontStatus(nextFrontStatus);
|
||||
}
|
||||
if (tournamentState !== undefined) {
|
||||
tournamentStage.value = tournamentState?.stage ?? 0;
|
||||
}
|
||||
if (initializedMailboxGeneralId !== id) {
|
||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||
initializedMailboxGeneralId = id;
|
||||
@@ -705,14 +717,18 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
|
||||
? trpc.tournament.getState.query().catch(() => undefined)
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
||||
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([
|
||||
lobbyPromise,
|
||||
mapPromise,
|
||||
contactsPromise,
|
||||
reservedPromise,
|
||||
recordsPromise,
|
||||
frontPromise,
|
||||
tournamentPromise,
|
||||
]);
|
||||
|
||||
const patch: DashboardReadModelPatch = { ...contextPatch };
|
||||
@@ -733,6 +749,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
patch.worldHistory = nextWorldHistory;
|
||||
}
|
||||
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
|
||||
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
|
||||
applyDashboardPatch(patch);
|
||||
publishDashboardPatch(patch);
|
||||
} catch (err) {
|
||||
@@ -1241,6 +1258,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
generalRecords,
|
||||
worldHistory,
|
||||
frontStatus,
|
||||
tournamentStage,
|
||||
surveyNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
|
||||
@@ -27,7 +27,6 @@ const session = useSessionStore();
|
||||
const dashboard = useMainDashboardStore();
|
||||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||
|
||||
const tournamentStage = ref(0);
|
||||
const npcMode = ref(0);
|
||||
|
||||
const {
|
||||
@@ -52,6 +51,7 @@ const {
|
||||
generalRecords,
|
||||
worldHistory,
|
||||
frontStatus,
|
||||
tournamentStage,
|
||||
surveyNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
@@ -107,12 +107,10 @@ const repeatGeneralTurns = (amount: number) => {
|
||||
};
|
||||
|
||||
const loadMainData = async () => {
|
||||
const [, state, worldState] = await Promise.all([
|
||||
const [, worldState] = await Promise.all([
|
||||
dashboard.loadMainData(),
|
||||
trpc.tournament.getState.query().catch(() => null),
|
||||
trpc.world.getState.query().catch(() => null),
|
||||
]);
|
||||
tournamentStage.value = state?.stage ?? 0;
|
||||
npcMode.value = worldState?.config.npcMode ?? 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ void test('last-turn-time-only events do not schedule any dashboard query', () =
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +47,7 @@ void test('selects only the read models affected by the current identity', () =>
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +81,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
});
|
||||
|
||||
const taxRate = resolveDashboardRefreshPlan(
|
||||
@@ -100,6 +103,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
});
|
||||
|
||||
const cityState = resolveDashboardRefreshPlan(
|
||||
@@ -161,6 +165,7 @@ void test('refreshes only front status for a global survey projection change', (
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: true,
|
||||
tournament: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,8 +179,8 @@ void test('targets a submitted survey projection to its own general', () => {
|
||||
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
|
||||
});
|
||||
|
||||
void test('keeps the access bundle projection-free for map, records, and front-status-only plans', () => {
|
||||
for (const slice of ['map', 'records', 'frontStatus'] as const) {
|
||||
void test('keeps the access bundle projection-free for independent read-model plans', () => {
|
||||
for (const slice of ['map', 'records', 'frontStatus', 'tournament'] as const) {
|
||||
const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true };
|
||||
assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
|
||||
context: false,
|
||||
|
||||
Reference in New Issue
Block a user