refactor(game): invalidate field-level read-model projections
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
import {
|
||||||
|
buildGameEventChannel,
|
||||||
|
buildGameReadModelRevisionKey,
|
||||||
|
type RealtimeEvent,
|
||||||
|
type RealtimeReadModelChanges,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
|
|
||||||
// 게임 서버의 실시간 이벤트를 Redis pub/sub 채널로 송신한다.
|
// 게임 서버의 실시간 이벤트를 Redis pub/sub 채널로 송신한다.
|
||||||
export const publishRealtimeEvent = async (
|
export const publishRealtimeEvent = async (
|
||||||
@@ -10,3 +15,18 @@ export const publishRealtimeEvent = async (
|
|||||||
const channel = buildGameEventChannel(profileName);
|
const channel = buildGameEventChannel(profileName);
|
||||||
await redis.publish(channel, JSON.stringify(event));
|
await redis.publish(channel, JSON.stringify(event));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const publishRealtimeReadModelChanges = async (
|
||||||
|
redis: RedisConnector['client'],
|
||||||
|
profileName: string,
|
||||||
|
changes: RealtimeReadModelChanges
|
||||||
|
): Promise<number> => {
|
||||||
|
const revision = await redis.incr(buildGameReadModelRevisionKey(profileName));
|
||||||
|
await publishRealtimeEvent(redis, profileName, {
|
||||||
|
type: 'readModelChanged',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
changes,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
return revision;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asRecord, createEmptyRealtimeReadModelChanges, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
ITEM_KEYS,
|
ITEM_KEYS,
|
||||||
@@ -17,8 +17,24 @@ import {
|
|||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
|
import type { GameApiContext } from '../../context.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||||
|
import { publishRealtimeReadModelChanges } from '../../realtime/publisher.js';
|
||||||
|
|
||||||
|
const publishFrontStatusChange = async (
|
||||||
|
ctx: GameApiContext,
|
||||||
|
options: { generalId?: number; global?: boolean }
|
||||||
|
): Promise<void> => {
|
||||||
|
const changes = createEmptyRealtimeReadModelChanges();
|
||||||
|
if (options.generalId) changes.frontStatusActorIds = [options.generalId];
|
||||||
|
if (options.global) changes.frontStatusChanged = true;
|
||||||
|
try {
|
||||||
|
await publishRealtimeReadModelChanges(ctx.redis, ctx.profile.name, changes);
|
||||||
|
} catch {
|
||||||
|
// 설문 DB mutation은 이미 commit되었으므로 실시간 알림 실패로 되돌리지 않는다.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||||
@@ -507,6 +523,7 @@ export const voteRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await publishFrontStatusChange(ctx, { generalId: general.id });
|
||||||
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
||||||
}),
|
}),
|
||||||
addComment: authedProcedure
|
addComment: authedProcedure
|
||||||
@@ -617,6 +634,7 @@ export const voteRouter = router({
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await publishFrontStatusChange(ctx, { global: true });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
updatePoll: adminProcedure
|
updatePoll: adminProcedure
|
||||||
@@ -713,6 +731,9 @@ export const voteRouter = router({
|
|||||||
WHERE id = ${input.voteId}
|
WHERE id = ${input.voteId}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
if (input.title !== undefined || endAt !== undefined) {
|
||||||
|
await publishFrontStatusChange(ctx, { global: true });
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
closePoll: adminProcedure
|
closePoll: adminProcedure
|
||||||
@@ -727,6 +748,7 @@ export const voteRouter = router({
|
|||||||
if (!rows[0]?.id) {
|
if (!rows[0]?.id) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' });
|
||||||
}
|
}
|
||||||
|
await publishFrontStatusChange(ctx, { global: true });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ const buildContext = (options: {
|
|||||||
generalId: general?.id ?? 0,
|
generalId: general?.id ?? 0,
|
||||||
awardedUnique: false,
|
awardedUnique: false,
|
||||||
}));
|
}));
|
||||||
|
const redisIncr = vi.fn(async (_key: string) => 41);
|
||||||
|
const redisPublish = vi.fn(async (_channel: string, _message: string) => 1);
|
||||||
const queryRaw = vi.fn(async (query: GamePrisma.Sql) => {
|
const queryRaw = vi.fn(async (query: GamePrisma.Sql) => {
|
||||||
const text = sqlText(query);
|
const text = sqlText(query);
|
||||||
if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) {
|
if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) {
|
||||||
@@ -177,7 +179,10 @@ const buildContext = (options: {
|
|||||||
);
|
);
|
||||||
const context: GameApiContext = {
|
const context: GameApiContext = {
|
||||||
db: db as unknown as DatabaseClient,
|
db: db as unknown as DatabaseClient,
|
||||||
redis: {} as RedisConnector['client'],
|
redis: {
|
||||||
|
incr: redisIncr,
|
||||||
|
publish: redisPublish,
|
||||||
|
} as unknown as RedisConnector['client'],
|
||||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
@@ -189,7 +194,7 @@ const buildContext = (options: {
|
|||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
};
|
};
|
||||||
return { context, requestCommand, queryRaw, db };
|
return { context, requestCommand, queryRaw, db, redisIncr, redisPublish };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('vote router actor and permission boundaries', () => {
|
describe('vote router actor and permission boundaries', () => {
|
||||||
@@ -216,6 +221,34 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
goldReward: 90,
|
goldReward: 90,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
expect(fixture.redisIncr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
||||||
|
const published = JSON.parse(String(fixture.redisPublish.mock.calls[0]?.[1]));
|
||||||
|
expect(published).toMatchObject({
|
||||||
|
type: 'readModelChanged',
|
||||||
|
revision: 41,
|
||||||
|
changes: {
|
||||||
|
frontStatusActorIds: [7],
|
||||||
|
frontStatusChanged: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes a global front-status projection after creating a survey', async () => {
|
||||||
|
const fixture = buildContext({ auth: buildAuth(['admin.survey.open']) });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).vote.createPoll({
|
||||||
|
title: '새 설문',
|
||||||
|
options: ['찬성', '반대'],
|
||||||
|
revealMode: 'after_vote',
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
|
const published = JSON.parse(String(fixture.redisPublish.mock.calls[0]?.[1]));
|
||||||
|
expect(published).toMatchObject({
|
||||||
|
type: 'readModelChanged',
|
||||||
|
changes: { frontStatusChanged: true },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the current world develcost for the legacy five-times survey reward', async () => {
|
it('uses the current world develcost for the legacy five-times survey reward', async () => {
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
LogFormat,
|
LogFormat,
|
||||||
LogScope,
|
LogScope,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
type City,
|
||||||
type LogEntryDraft,
|
type LogEntryDraft,
|
||||||
type MessageRecordDraft,
|
type MessageRecordDraft,
|
||||||
|
type Nation,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { asRecord, type RealtimeReadModelChanges } from '@sammo-ts/common';
|
import { asRecord, type RealtimeReadModelChanges } from '@sammo-ts/common';
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence
|
|||||||
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
||||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||||
|
import type { TurnGeneral } from './types.js';
|
||||||
import { buildPersistedRankRows } from './rankData.js';
|
import { buildPersistedRankRows } from './rankData.js';
|
||||||
import { persistUnificationFinalization } from './unificationPersistence.js';
|
import { persistUnificationFinalization } from './unificationPersistence.js';
|
||||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||||
@@ -47,10 +50,124 @@ export interface DatabaseTurnHooks {
|
|||||||
const uniqueSortedIds = (values: Iterable<number>): number[] =>
|
const uniqueSortedIds = (values: Iterable<number>): number[] =>
|
||||||
[...new Set(values)].filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => left - right);
|
[...new Set(values)].filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => left - right);
|
||||||
|
|
||||||
export const summarizeRealtimeReadModelChanges = (
|
export type ReadModelSignatures = {
|
||||||
changes: TurnWorldChanges,
|
content: string;
|
||||||
reservedTurnChanges?: ReservedTurnChanges
|
map: string;
|
||||||
): RealtimeReadModelChanges => {
|
contacts: string;
|
||||||
|
frontStatus: string;
|
||||||
|
frontStatusGlobal: string;
|
||||||
|
lobbyCount: string;
|
||||||
|
lobbyPersonal: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RealtimeReadModelBaseline {
|
||||||
|
generals: Map<number, ReadModelSignatures>;
|
||||||
|
cities: Map<number, ReadModelSignatures>;
|
||||||
|
nations: Map<number, ReadModelSignatures>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalizeReadModelValue = (value: unknown): unknown => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(canonicalizeReadModelValue);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, item]) => [key, canonicalizeReadModelValue(item)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const signature = (value: unknown): string => JSON.stringify(canonicalizeReadModelValue(value)) ?? 'undefined';
|
||||||
|
|
||||||
|
const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
|
||||||
|
content: signature(general),
|
||||||
|
map: signature({ cityId: general.cityId, nationId: general.nationId }),
|
||||||
|
contacts: signature({
|
||||||
|
name: general.name,
|
||||||
|
nationId: general.nationId,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
npcState: general.npcState,
|
||||||
|
permission: asRecord(general.meta).permission,
|
||||||
|
penalty: general.penalty,
|
||||||
|
}),
|
||||||
|
frontStatus: signature({ name: general.name, nationId: general.nationId }),
|
||||||
|
frontStatusGlobal: '',
|
||||||
|
lobbyCount: signature({ npcState: general.npcState }),
|
||||||
|
lobbyPersonal: signature({
|
||||||
|
name: general.name,
|
||||||
|
picture: general.picture,
|
||||||
|
imageServer: general.imageServer,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const citySignatures = (city: City): ReadModelSignatures => ({
|
||||||
|
content: signature(city),
|
||||||
|
map: signature({
|
||||||
|
level: city.level,
|
||||||
|
nationId: city.nationId,
|
||||||
|
state: city.state,
|
||||||
|
supplyState: city.supplyState,
|
||||||
|
}),
|
||||||
|
contacts: '',
|
||||||
|
frontStatus: '',
|
||||||
|
frontStatusGlobal: '',
|
||||||
|
lobbyCount: '',
|
||||||
|
lobbyPersonal: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const nationSignatures = (nation: Nation): ReadModelSignatures => ({
|
||||||
|
content: signature(nation),
|
||||||
|
map: signature({
|
||||||
|
name: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
capitalCityId: nation.capitalCityId,
|
||||||
|
}),
|
||||||
|
contacts: signature({ name: nation.name, color: nation.color }),
|
||||||
|
frontStatus: signature({ notice: asRecord(nation.meta).notice }),
|
||||||
|
frontStatusGlobal: signature({ name: nation.name }),
|
||||||
|
lobbyCount: signature({ level: nation.level }),
|
||||||
|
lobbyPersonal: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createRealtimeReadModelBaseline = (world: InMemoryTurnWorld): RealtimeReadModelBaseline => ({
|
||||||
|
generals: new Map(world.listGenerals().map((general) => [general.id, generalSignatures(general)])),
|
||||||
|
cities: new Map(world.listCities().map((city) => [city.id, citySignatures(city)])),
|
||||||
|
nations: new Map(world.listNations().map((nation) => [nation.id, nationSignatures(nation)])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changedProjectionIds = (
|
||||||
|
candidateIds: readonly number[],
|
||||||
|
baseline: ReadonlyMap<number, ReadModelSignatures>,
|
||||||
|
final: ReadonlyMap<number, ReadModelSignatures>,
|
||||||
|
projection: keyof ReadModelSignatures
|
||||||
|
): number[] => uniqueSortedIds(candidateIds.filter((id) => baseline.get(id)?.[projection] !== final.get(id)?.[projection]));
|
||||||
|
|
||||||
|
const buildFinalSignatures = <Entity extends { id: number }>(
|
||||||
|
entities: readonly Entity[],
|
||||||
|
project: (entity: Entity) => ReadModelSignatures
|
||||||
|
): Map<number, ReadModelSignatures> => new Map(entities.map((entity) => [entity.id, project(entity)]));
|
||||||
|
|
||||||
|
export const applyRealtimeReadModelBaseline = (
|
||||||
|
baseline: RealtimeReadModelBaseline,
|
||||||
|
changes: TurnWorldChanges
|
||||||
|
): void => {
|
||||||
|
const apply = (
|
||||||
|
target: Map<number, ReadModelSignatures>,
|
||||||
|
candidateIds: readonly number[],
|
||||||
|
final: ReadonlyMap<number, ReadModelSignatures>
|
||||||
|
) => {
|
||||||
|
for (const id of candidateIds) {
|
||||||
|
const next = final.get(id);
|
||||||
|
if (next) target.set(id, next);
|
||||||
|
else target.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
const generalIds = uniqueSortedIds([
|
const generalIds = uniqueSortedIds([
|
||||||
...changes.generals.map((general) => general.id),
|
...changes.generals.map((general) => general.id),
|
||||||
...changes.createdGenerals.map((general) => general.id),
|
...changes.createdGenerals.map((general) => general.id),
|
||||||
@@ -64,6 +181,81 @@ export const summarizeRealtimeReadModelChanges = (
|
|||||||
...changes.deletedNations,
|
...changes.deletedNations,
|
||||||
...changes.deletedNationSnapshots.map((snapshot) => snapshot.nation.id),
|
...changes.deletedNationSnapshots.map((snapshot) => snapshot.nation.id),
|
||||||
]);
|
]);
|
||||||
|
apply(
|
||||||
|
baseline.generals,
|
||||||
|
generalIds,
|
||||||
|
buildFinalSignatures([...changes.generals, ...changes.createdGenerals], generalSignatures)
|
||||||
|
);
|
||||||
|
apply(baseline.cities, cityIds, buildFinalSignatures(changes.cities, citySignatures));
|
||||||
|
apply(
|
||||||
|
baseline.nations,
|
||||||
|
nationIds,
|
||||||
|
buildFinalSignatures([...changes.nations, ...changes.createdNations], nationSignatures)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const summarizeRealtimeReadModelChanges = (
|
||||||
|
changes: TurnWorldChanges,
|
||||||
|
reservedTurnChanges?: ReservedTurnChanges,
|
||||||
|
baseline?: RealtimeReadModelBaseline
|
||||||
|
): RealtimeReadModelChanges => {
|
||||||
|
const generalCandidates = uniqueSortedIds([
|
||||||
|
...changes.generals.map((general) => general.id),
|
||||||
|
...changes.createdGenerals.map((general) => general.id),
|
||||||
|
...changes.deletedGenerals,
|
||||||
|
...changes.lifecycleEvents.map((event) => event.generalId),
|
||||||
|
]);
|
||||||
|
const cityCandidates = uniqueSortedIds(changes.cities.map((city) => city.id));
|
||||||
|
const nationCandidates = uniqueSortedIds([
|
||||||
|
...changes.nations.map((nation) => nation.id),
|
||||||
|
...changes.createdNations.map((nation) => nation.id),
|
||||||
|
...changes.deletedNations,
|
||||||
|
...changes.deletedNationSnapshots.map((snapshot) => snapshot.nation.id),
|
||||||
|
]);
|
||||||
|
const finalGenerals = buildFinalSignatures(
|
||||||
|
[...changes.generals, ...changes.createdGenerals],
|
||||||
|
generalSignatures
|
||||||
|
);
|
||||||
|
const finalCities = buildFinalSignatures(changes.cities, citySignatures);
|
||||||
|
const finalNations = buildFinalSignatures([...changes.nations, ...changes.createdNations], nationSignatures);
|
||||||
|
const generalIds = baseline
|
||||||
|
? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'content')
|
||||||
|
: generalCandidates;
|
||||||
|
const cityIds = baseline
|
||||||
|
? changedProjectionIds(cityCandidates, baseline.cities, finalCities, 'content')
|
||||||
|
: cityCandidates;
|
||||||
|
const nationIds = baseline
|
||||||
|
? changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'content')
|
||||||
|
: nationCandidates;
|
||||||
|
const mapGeneralIds = baseline
|
||||||
|
? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'map')
|
||||||
|
: generalIds;
|
||||||
|
const mapCityIds = baseline
|
||||||
|
? changedProjectionIds(cityCandidates, baseline.cities, finalCities, 'map')
|
||||||
|
: cityIds;
|
||||||
|
const mapNationIds = baseline
|
||||||
|
? changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'map')
|
||||||
|
: nationIds;
|
||||||
|
const frontStatusNationIds = baseline
|
||||||
|
? changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'frontStatus')
|
||||||
|
: nationIds;
|
||||||
|
const frontStatusGeneralIds = baseline
|
||||||
|
? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'frontStatus')
|
||||||
|
: generalIds;
|
||||||
|
const frontStatusChanged = baseline
|
||||||
|
? frontStatusGeneralIds.length > 0 ||
|
||||||
|
changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'frontStatusGlobal').length > 0
|
||||||
|
: false;
|
||||||
|
const lobbyGeneralIds = baseline
|
||||||
|
? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'lobbyPersonal')
|
||||||
|
: generalIds;
|
||||||
|
const lobbyChanged = baseline
|
||||||
|
? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'lobbyCount').length > 0 ||
|
||||||
|
changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'lobbyCount').length > 0
|
||||||
|
: changes.createdGenerals.length > 0 ||
|
||||||
|
changes.deletedGenerals.length > 0 ||
|
||||||
|
changes.createdNations.length > 0 ||
|
||||||
|
changes.deletedNations.length > 0;
|
||||||
const reservedGeneralIds = uniqueSortedIds(
|
const reservedGeneralIds = uniqueSortedIds(
|
||||||
reservedTurnChanges
|
reservedTurnChanges
|
||||||
? [
|
? [
|
||||||
@@ -88,34 +280,44 @@ export const summarizeRealtimeReadModelChanges = (
|
|||||||
const worldHistoryChanged = changes.logs.some(
|
const worldHistoryChanged = changes.logs.some(
|
||||||
(entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY
|
(entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY
|
||||||
);
|
);
|
||||||
const contactsChanged =
|
const contactsChanged = baseline
|
||||||
changes.createdGenerals.length > 0 ||
|
? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'contacts').length > 0 ||
|
||||||
changes.deletedGenerals.length > 0 ||
|
changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'contacts').length > 0
|
||||||
changes.createdNations.length > 0 ||
|
: changes.createdGenerals.length > 0 ||
|
||||||
changes.deletedNations.length > 0 ||
|
changes.deletedGenerals.length > 0 ||
|
||||||
changes.lifecycleEvents.some((event) => {
|
changes.createdNations.length > 0 ||
|
||||||
const after = event.after;
|
changes.deletedNations.length > 0 ||
|
||||||
const beforePermission = asRecord(event.before.meta).permission;
|
changes.lifecycleEvents.some((event) => {
|
||||||
const afterPermission = after ? asRecord(after.meta).permission : undefined;
|
const after = event.after;
|
||||||
return (
|
const beforePermission = asRecord(event.before.meta).permission;
|
||||||
!after ||
|
const afterPermission = after ? asRecord(after.meta).permission : undefined;
|
||||||
event.before.name !== after.name ||
|
return (
|
||||||
event.before.nationId !== after.nationId ||
|
!after ||
|
||||||
event.before.officerLevel !== after.officerLevel ||
|
event.before.name !== after.name ||
|
||||||
beforePermission !== afterPermission
|
event.before.nationId !== after.nationId ||
|
||||||
);
|
event.before.officerLevel !== after.officerLevel ||
|
||||||
});
|
beforePermission !== afterPermission
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generalIds,
|
generalIds,
|
||||||
cityIds,
|
cityIds,
|
||||||
nationIds,
|
nationIds,
|
||||||
|
mapGeneralIds,
|
||||||
|
mapCityIds,
|
||||||
|
mapNationIds,
|
||||||
|
frontStatusGeneralIds,
|
||||||
|
frontStatusNationIds,
|
||||||
|
lobbyGeneralIds,
|
||||||
reservedGeneralIds,
|
reservedGeneralIds,
|
||||||
recordGeneralIds,
|
recordGeneralIds,
|
||||||
worldChanged: false,
|
worldChanged: false,
|
||||||
globalRecordsChanged,
|
globalRecordsChanged,
|
||||||
worldHistoryChanged,
|
worldHistoryChanged,
|
||||||
contactsChanged,
|
contactsChanged,
|
||||||
|
frontStatusChanged,
|
||||||
|
lobbyChanged,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -665,6 +867,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
const prisma = connector.prisma;
|
const prisma = connector.prisma;
|
||||||
let committedReadModelChanges: RealtimeReadModelChanges | null = null;
|
let committedReadModelChanges: RealtimeReadModelChanges | null = null;
|
||||||
|
const readModelBaseline = createRealtimeReadModelBaseline(world);
|
||||||
|
|
||||||
const persistChanges = async (
|
const persistChanges = async (
|
||||||
transaction?: GamePrisma.TransactionClient,
|
transaction?: GamePrisma.TransactionClient,
|
||||||
@@ -1136,14 +1339,20 @@ export const createDatabaseTurnHooks = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readModelChanges = summarizeRealtimeReadModelChanges(
|
||||||
|
changes,
|
||||||
|
persistedReservedTurnChanges,
|
||||||
|
readModelBaseline
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
acknowledge: () => {
|
acknowledge: () => {
|
||||||
world.acknowledgeDirtyState(changes);
|
world.acknowledgeDirtyState(changes);
|
||||||
if (options?.reservedTurns && reservedTurnChanges) {
|
if (options?.reservedTurns && reservedTurnChanges) {
|
||||||
options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges);
|
options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges);
|
||||||
}
|
}
|
||||||
|
applyRealtimeReadModelBaseline(readModelBaseline, changes);
|
||||||
},
|
},
|
||||||
readModelChanges: summarizeRealtimeReadModelChanges(changes, persistedReservedTurnChanges),
|
readModelChanges,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -746,7 +746,11 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
const revisionKey = buildGameReadModelRevisionKey(options.profileName ?? options.profile);
|
const revisionKey = buildGameReadModelRevisionKey(options.profileName ?? options.profile);
|
||||||
const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName ?? options.profile);
|
const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName ?? options.profile);
|
||||||
publishReadModelChanges = async (changes) => {
|
publishReadModelChanges = async (changes) => {
|
||||||
if (changes.worldChanged || changes.cityIds.length > 0 || changes.nationIds.length > 0) {
|
if (
|
||||||
|
changes.worldChanged ||
|
||||||
|
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
|
||||||
|
(changes.mapNationIds ?? changes.nationIds).length > 0
|
||||||
|
) {
|
||||||
await redisClient.hIncrBy(domainRevisionKey, 'world', 1);
|
await redisClient.hIncrBy(domainRevisionKey, 'world', 1);
|
||||||
}
|
}
|
||||||
return redisClient.incr(revisionKey);
|
return redisClient.incr(revisionKey);
|
||||||
@@ -787,6 +791,9 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
publishCommandEvents: async (result) => {
|
publishCommandEvents: async (result) => {
|
||||||
try {
|
try {
|
||||||
const changes = takeCommittedReadModelChanges?.();
|
const changes = takeCommittedReadModelChanges?.();
|
||||||
|
if (changes && result.type === 'shiftSchedule' && result.ok) {
|
||||||
|
changes.lobbyChanged = true;
|
||||||
|
}
|
||||||
if (changes && hasRealtimeReadModelChanges(changes)) {
|
if (changes && hasRealtimeReadModelChanges(changes)) {
|
||||||
const revision = await publishCommittedChanges(changes);
|
const revision = await publishCommittedChanges(changes);
|
||||||
if (revision !== undefined) {
|
if (revision !== undefined) {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { summarizeRealtimeReadModelChanges } from '../src/turn/databaseHooks.js';
|
import {
|
||||||
|
applyRealtimeReadModelBaseline,
|
||||||
|
createRealtimeReadModelBaseline,
|
||||||
|
summarizeRealtimeReadModelChanges,
|
||||||
|
} from '../src/turn/databaseHooks.js';
|
||||||
|
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
|
import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
|
||||||
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
|
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
|
||||||
|
|
||||||
@@ -52,12 +57,212 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
|||||||
generalIds: [7, 8, 9],
|
generalIds: [7, 8, 9],
|
||||||
cityIds: [4],
|
cityIds: [4],
|
||||||
nationIds: [3],
|
nationIds: [3],
|
||||||
|
mapGeneralIds: [7, 8, 9],
|
||||||
|
mapCityIds: [4],
|
||||||
|
mapNationIds: [3],
|
||||||
|
frontStatusGeneralIds: [7, 8, 9],
|
||||||
|
frontStatusNationIds: [3],
|
||||||
|
lobbyGeneralIds: [7, 8, 9],
|
||||||
reservedGeneralIds: [7, 8, 9],
|
reservedGeneralIds: [7, 8, 9],
|
||||||
recordGeneralIds: [7],
|
recordGeneralIds: [7],
|
||||||
worldChanged: false,
|
worldChanged: false,
|
||||||
globalRecordsChanged: true,
|
globalRecordsChanged: true,
|
||||||
worldHistoryChanged: true,
|
worldHistoryChanged: true,
|
||||||
contactsChanged: true,
|
contactsChanged: true,
|
||||||
|
frontStatusChanged: false,
|
||||||
|
lobbyChanged: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates content changes from map and contact projections', () => {
|
||||||
|
const baseline = createRealtimeReadModelBaseline({
|
||||||
|
listGenerals: () => [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '장수',
|
||||||
|
cityId: 1,
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 1,
|
||||||
|
npcState: 0,
|
||||||
|
gold: 100,
|
||||||
|
meta: { permission: 'normal' },
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
listCities: () => [
|
||||||
|
{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 },
|
||||||
|
],
|
||||||
|
listNations: () => [
|
||||||
|
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 },
|
||||||
|
],
|
||||||
|
} as unknown as InMemoryTurnWorld);
|
||||||
|
const changes = {
|
||||||
|
generals: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '장수',
|
||||||
|
cityId: 1,
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 1,
|
||||||
|
npcState: 0,
|
||||||
|
gold: 90,
|
||||||
|
meta: { permission: 'normal' },
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdGenerals: [],
|
||||||
|
deletedGenerals: [],
|
||||||
|
cities: [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 101 }],
|
||||||
|
nations: [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 90 }],
|
||||||
|
createdNations: [],
|
||||||
|
deletedNations: [],
|
||||||
|
deletedNationSnapshots: [],
|
||||||
|
lifecycleEvents: [],
|
||||||
|
logs: [],
|
||||||
|
} as unknown as TurnWorldChanges;
|
||||||
|
|
||||||
|
expect(summarizeRealtimeReadModelChanges(changes, undefined, baseline)).toMatchObject({
|
||||||
|
generalIds: [7],
|
||||||
|
cityIds: [1],
|
||||||
|
nationIds: [1],
|
||||||
|
mapGeneralIds: [],
|
||||||
|
mapCityIds: [],
|
||||||
|
mapNationIds: [],
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
frontStatusNationIds: [],
|
||||||
|
lobbyGeneralIds: [],
|
||||||
|
contactsChanged: false,
|
||||||
|
frontStatusChanged: false,
|
||||||
|
lobbyChanged: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyRealtimeReadModelBaseline(baseline, changes);
|
||||||
|
expect(summarizeRealtimeReadModelChanges(changes, undefined, baseline)).toMatchObject({
|
||||||
|
generalIds: [],
|
||||||
|
cityIds: [],
|
||||||
|
nationIds: [],
|
||||||
|
mapGeneralIds: [],
|
||||||
|
mapCityIds: [],
|
||||||
|
mapNationIds: [],
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
frontStatusNationIds: [],
|
||||||
|
lobbyGeneralIds: [],
|
||||||
|
contactsChanged: false,
|
||||||
|
frontStatusChanged: false,
|
||||||
|
lobbyChanged: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects map, contact, and front-status fields independently', () => {
|
||||||
|
const baseline = createRealtimeReadModelBaseline({
|
||||||
|
listGenerals: () => [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '장수',
|
||||||
|
cityId: 1,
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 1,
|
||||||
|
npcState: 0,
|
||||||
|
meta: { permission: 'normal' },
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1 }],
|
||||||
|
listNations: () => [
|
||||||
|
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
|
||||||
|
],
|
||||||
|
} as unknown as InMemoryTurnWorld);
|
||||||
|
const changes = {
|
||||||
|
generals: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '장수',
|
||||||
|
cityId: 1,
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 5,
|
||||||
|
npcState: 0,
|
||||||
|
meta: { permission: 'normal' },
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdGenerals: [],
|
||||||
|
deletedGenerals: [],
|
||||||
|
cities: [{ id: 1, level: 2, nationId: 1, state: 0, supplyState: 1 }],
|
||||||
|
nations: [
|
||||||
|
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } },
|
||||||
|
],
|
||||||
|
createdNations: [],
|
||||||
|
deletedNations: [],
|
||||||
|
deletedNationSnapshots: [],
|
||||||
|
lifecycleEvents: [],
|
||||||
|
logs: [],
|
||||||
|
} as unknown as TurnWorldChanges;
|
||||||
|
|
||||||
|
expect(summarizeRealtimeReadModelChanges(changes, undefined, baseline)).toMatchObject({
|
||||||
|
generalIds: [7],
|
||||||
|
cityIds: [1],
|
||||||
|
nationIds: [1],
|
||||||
|
mapGeneralIds: [],
|
||||||
|
mapCityIds: [1],
|
||||||
|
mapNationIds: [],
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
frontStatusNationIds: [1],
|
||||||
|
lobbyGeneralIds: [],
|
||||||
|
contactsChanged: true,
|
||||||
|
frontStatusChanged: false,
|
||||||
|
lobbyChanged: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates global front-status names from nation-targeted notices', () => {
|
||||||
|
const baseline = createRealtimeReadModelBaseline({
|
||||||
|
listGenerals: () => [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '장수',
|
||||||
|
cityId: 1,
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 1,
|
||||||
|
npcState: 0,
|
||||||
|
meta: {},
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
listCities: () => [],
|
||||||
|
listNations: () => [
|
||||||
|
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
|
||||||
|
],
|
||||||
|
} as unknown as InMemoryTurnWorld);
|
||||||
|
const changes = {
|
||||||
|
generals: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '새 장수',
|
||||||
|
cityId: 1,
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 1,
|
||||||
|
npcState: 0,
|
||||||
|
meta: {},
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdGenerals: [],
|
||||||
|
deletedGenerals: [],
|
||||||
|
cities: [],
|
||||||
|
nations: [
|
||||||
|
{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } },
|
||||||
|
],
|
||||||
|
createdNations: [],
|
||||||
|
deletedNations: [],
|
||||||
|
deletedNationSnapshots: [],
|
||||||
|
lifecycleEvents: [],
|
||||||
|
logs: [],
|
||||||
|
} as unknown as TurnWorldChanges;
|
||||||
|
|
||||||
|
expect(summarizeRealtimeReadModelChanges(changes, undefined, baseline)).toMatchObject({
|
||||||
|
frontStatusGeneralIds: [7],
|
||||||
|
frontStatusNationIds: [1],
|
||||||
|
frontStatusChanged: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -601,6 +601,15 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
generalIds: [7],
|
generalIds: [7],
|
||||||
cityIds: [],
|
cityIds: [],
|
||||||
nationIds: [],
|
nationIds: [],
|
||||||
|
mapGeneralIds: [],
|
||||||
|
mapCityIds: [],
|
||||||
|
mapNationIds: [],
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
frontStatusNationIds: [],
|
||||||
|
frontStatusActorIds: [],
|
||||||
|
frontStatusChanged: false,
|
||||||
|
lobbyGeneralIds: [],
|
||||||
|
lobbyChanged: false,
|
||||||
reservedGeneralIds: [],
|
reservedGeneralIds: [],
|
||||||
recordGeneralIds: [],
|
recordGeneralIds: [],
|
||||||
worldChanged: false,
|
worldChanged: false,
|
||||||
@@ -620,11 +629,12 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수');
|
await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수');
|
||||||
const changedOperations = state.operations.slice(operationsBeforeChangedBurst);
|
const changedOperations = state.operations.slice(operationsBeforeChangedBurst);
|
||||||
expect(changedOperations).toEqual(
|
expect(changedOperations).toEqual(
|
||||||
expect.arrayContaining(['general.me', 'world.getMap', 'turns.getCommandTable', 'board.getAccess'])
|
expect.arrayContaining(['general.me', 'turns.getCommandTable', 'board.getAccess'])
|
||||||
);
|
);
|
||||||
expect(changedOperations).not.toEqual(
|
expect(changedOperations).not.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'lobby.info',
|
'lobby.info',
|
||||||
|
'world.getMap',
|
||||||
'messages.getRecent',
|
'messages.getRecent',
|
||||||
'messages.getContacts',
|
'messages.getContacts',
|
||||||
'general.getRecentRecords',
|
'general.getRecentRecords',
|
||||||
@@ -633,6 +643,40 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const operationsBeforeSurvey = state.operations.length;
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||||
|
'readModelChanged',
|
||||||
|
{
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
revision: 42,
|
||||||
|
changes: {
|
||||||
|
generalIds: [],
|
||||||
|
cityIds: [],
|
||||||
|
nationIds: [],
|
||||||
|
mapGeneralIds: [],
|
||||||
|
mapCityIds: [],
|
||||||
|
mapNationIds: [],
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
frontStatusNationIds: [],
|
||||||
|
frontStatusActorIds: [],
|
||||||
|
frontStatusChanged: true,
|
||||||
|
lobbyGeneralIds: [],
|
||||||
|
lobbyChanged: false,
|
||||||
|
reservedGeneralIds: [],
|
||||||
|
recordGeneralIds: [],
|
||||||
|
worldChanged: false,
|
||||||
|
globalRecordsChanged: false,
|
||||||
|
worldHistoryChanged: false,
|
||||||
|
contactsChanged: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await expect.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 }).toEqual([
|
||||||
|
'general.getFrontStatus',
|
||||||
|
]);
|
||||||
|
|
||||||
const profile = await page.evaluate(() => {
|
const profile = await page.evaluate(() => {
|
||||||
const probe = (
|
const probe = (
|
||||||
window as unknown as {
|
window as unknown as {
|
||||||
@@ -697,6 +741,15 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
generalIds: [7],
|
generalIds: [7],
|
||||||
cityIds: [],
|
cityIds: [],
|
||||||
nationIds: [],
|
nationIds: [],
|
||||||
|
mapGeneralIds: [],
|
||||||
|
mapCityIds: [],
|
||||||
|
mapNationIds: [],
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
frontStatusNationIds: [],
|
||||||
|
frontStatusActorIds: [],
|
||||||
|
frontStatusChanged: false,
|
||||||
|
lobbyGeneralIds: [],
|
||||||
|
lobbyChanged: false,
|
||||||
reservedGeneralIds: [],
|
reservedGeneralIds: [],
|
||||||
recordGeneralIds: [],
|
recordGeneralIds: [],
|
||||||
worldChanged: false,
|
worldChanged: false,
|
||||||
|
|||||||
@@ -31,16 +31,31 @@ export const resolveDashboardRefreshPlan = (
|
|||||||
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
|
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
|
||||||
const ownCityChanged = contains(changes.cityIds, identity.cityId);
|
const ownCityChanged = contains(changes.cityIds, identity.cityId);
|
||||||
const ownNationChanged = contains(changes.nationIds, identity.nationId);
|
const ownNationChanged = contains(changes.nationIds, identity.nationId);
|
||||||
|
const ownFrontStatusNationChanged = contains(
|
||||||
|
changes.frontStatusNationIds ?? changes.nationIds,
|
||||||
|
identity.nationId
|
||||||
|
);
|
||||||
|
const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId);
|
||||||
|
const frontStatusGeneralChanged =
|
||||||
|
changes.frontStatusGeneralIds !== undefined
|
||||||
|
? changes.frontStatusGeneralIds.length > 0
|
||||||
|
: changes.contactsChanged;
|
||||||
|
const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId);
|
||||||
|
const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId);
|
||||||
|
const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged;
|
||||||
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
|
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
|
||||||
const worldEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
|
const mapEntitiesChanged =
|
||||||
|
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
|
||||||
|
(changes.mapNationIds ?? changes.nationIds).length > 0;
|
||||||
|
const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context: entityContextChanged,
|
context: entityContextChanged,
|
||||||
lobby: changes.worldChanged || changes.contactsChanged,
|
lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged,
|
||||||
map: changes.worldChanged || worldEntitiesChanged || ownGeneralChanged,
|
map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged,
|
||||||
commands: changes.worldChanged || worldEntitiesChanged || ownGeneralChanged,
|
commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged,
|
||||||
contacts: changes.contactsChanged,
|
contacts: changes.contactsChanged,
|
||||||
boardAccess: entityContextChanged,
|
boardAccess: ownGeneralChanged || ownNationChanged,
|
||||||
reservedTurns: contains(changes.reservedGeneralIds, identity.generalId),
|
reservedTurns: contains(changes.reservedGeneralIds, identity.generalId),
|
||||||
records:
|
records:
|
||||||
changes.globalRecordsChanged ||
|
changes.globalRecordsChanged ||
|
||||||
@@ -48,7 +63,11 @@ export const resolveDashboardRefreshPlan = (
|
|||||||
contains(changes.recordGeneralIds, identity.generalId),
|
contains(changes.recordGeneralIds, identity.generalId),
|
||||||
// lastTurnTime is intentionally excluded. This slice contains the
|
// lastTurnTime is intentionally excluded. This slice contains the
|
||||||
// nation notice/vote/presence model and only follows related changes.
|
// nation notice/vote/presence model and only follows related changes.
|
||||||
frontStatus: changes.contactsChanged || ownNationChanged,
|
frontStatus:
|
||||||
|
Boolean(changes.frontStatusChanged) ||
|
||||||
|
frontStatusGeneralChanged ||
|
||||||
|
ownFrontStatusNationChanged ||
|
||||||
|
ownFrontStatusActorChanged,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ void test('selects only the read models affected by the current identity', () =>
|
|||||||
assert.deepEqual(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }), {
|
assert.deepEqual(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }), {
|
||||||
context: true,
|
context: true,
|
||||||
lobby: false,
|
lobby: false,
|
||||||
map: true,
|
map: false,
|
||||||
commands: true,
|
commands: true,
|
||||||
contacts: false,
|
contacts: false,
|
||||||
boardAccess: true,
|
boardAccess: true,
|
||||||
@@ -48,6 +48,87 @@ void test('selects only the read models affected by the current identity', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('refreshes the map only for map-projection changes', () => {
|
||||||
|
const changes = {
|
||||||
|
...createEmptyRealtimeReadModelChanges(),
|
||||||
|
generalIds: [7],
|
||||||
|
mapGeneralIds: [7],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('keeps conservative map behavior for rolling-deploy payloads without projections', () => {
|
||||||
|
const changes = {
|
||||||
|
generalIds: [7],
|
||||||
|
cityIds: [],
|
||||||
|
nationIds: [],
|
||||||
|
reservedGeneralIds: [],
|
||||||
|
recordGeneralIds: [],
|
||||||
|
worldChanged: false,
|
||||||
|
globalRecordsChanged: false,
|
||||||
|
worldHistoryChanged: false,
|
||||||
|
contactsChanged: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('does not refresh front status for contact-only permission changes', () => {
|
||||||
|
const changes = {
|
||||||
|
...createEmptyRealtimeReadModelChanges(),
|
||||||
|
generalIds: [9],
|
||||||
|
contactsChanged: true,
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
};
|
||||||
|
const plan = resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 });
|
||||||
|
|
||||||
|
assert.equal(plan.contacts, true);
|
||||||
|
assert.equal(plan.lobby, false);
|
||||||
|
assert.equal(plan.frontStatus, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('refreshes only front status for a global survey projection change', () => {
|
||||||
|
const changes = {
|
||||||
|
...createEmptyRealtimeReadModelChanges(),
|
||||||
|
frontStatusChanged: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }), {
|
||||||
|
context: false,
|
||||||
|
lobby: false,
|
||||||
|
map: false,
|
||||||
|
commands: false,
|
||||||
|
contacts: false,
|
||||||
|
boardAccess: false,
|
||||||
|
reservedTurns: false,
|
||||||
|
records: false,
|
||||||
|
frontStatus: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('targets a submitted survey projection to its own general', () => {
|
||||||
|
const changes = {
|
||||||
|
...createEmptyRealtimeReadModelChanges(),
|
||||||
|
frontStatusActorIds: [7],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
void test('merges burst payloads without losing entity ids and starts at most once per interval', async () => {
|
void test('merges burst payloads without losing entity ids and starts at most once per interval', async () => {
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
let nextTimerId = 1;
|
let nextTimerId = 1;
|
||||||
|
|||||||
@@ -9,24 +9,48 @@ export interface RealtimeReadModelChanges {
|
|||||||
generalIds: number[];
|
generalIds: number[];
|
||||||
cityIds: number[];
|
cityIds: number[];
|
||||||
nationIds: number[];
|
nationIds: number[];
|
||||||
|
/** Entity IDs whose map projection changed. Absent on older daemons. */
|
||||||
|
mapGeneralIds?: number[];
|
||||||
|
mapCityIds?: number[];
|
||||||
|
mapNationIds?: number[];
|
||||||
|
/** Generals whose name/nation projection used by front status changed. */
|
||||||
|
frontStatusGeneralIds?: number[];
|
||||||
|
/** Nations whose viewer-specific notice projection changed. */
|
||||||
|
frontStatusNationIds?: number[];
|
||||||
|
/** Generals whose viewer-specific front-status projection changed. */
|
||||||
|
frontStatusActorIds?: number[];
|
||||||
|
/** Generals whose name/icon projection shown in their own lobby changed. */
|
||||||
|
lobbyGeneralIds?: number[];
|
||||||
reservedGeneralIds: number[];
|
reservedGeneralIds: number[];
|
||||||
recordGeneralIds: number[];
|
recordGeneralIds: number[];
|
||||||
worldChanged: boolean;
|
worldChanged: boolean;
|
||||||
globalRecordsChanged: boolean;
|
globalRecordsChanged: boolean;
|
||||||
worldHistoryChanged: boolean;
|
worldHistoryChanged: boolean;
|
||||||
contactsChanged: boolean;
|
contactsChanged: boolean;
|
||||||
|
/** A global front-status source such as the active survey changed. */
|
||||||
|
frontStatusChanged?: boolean;
|
||||||
|
lobbyChanged?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({
|
export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({
|
||||||
generalIds: [],
|
generalIds: [],
|
||||||
cityIds: [],
|
cityIds: [],
|
||||||
nationIds: [],
|
nationIds: [],
|
||||||
|
mapGeneralIds: [],
|
||||||
|
mapCityIds: [],
|
||||||
|
mapNationIds: [],
|
||||||
|
frontStatusGeneralIds: [],
|
||||||
|
frontStatusNationIds: [],
|
||||||
|
frontStatusActorIds: [],
|
||||||
|
lobbyGeneralIds: [],
|
||||||
reservedGeneralIds: [],
|
reservedGeneralIds: [],
|
||||||
recordGeneralIds: [],
|
recordGeneralIds: [],
|
||||||
worldChanged: false,
|
worldChanged: false,
|
||||||
globalRecordsChanged: false,
|
globalRecordsChanged: false,
|
||||||
worldHistoryChanged: false,
|
worldHistoryChanged: false,
|
||||||
contactsChanged: false,
|
contactsChanged: false,
|
||||||
|
frontStatusChanged: false,
|
||||||
|
lobbyChanged: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergeIds = (left: readonly number[], right: readonly number[]): number[] =>
|
const mergeIds = (left: readonly number[], right: readonly number[]): number[] =>
|
||||||
@@ -39,24 +63,42 @@ export const mergeRealtimeReadModelChanges = (
|
|||||||
generalIds: mergeIds(left.generalIds, right.generalIds),
|
generalIds: mergeIds(left.generalIds, right.generalIds),
|
||||||
cityIds: mergeIds(left.cityIds, right.cityIds),
|
cityIds: mergeIds(left.cityIds, right.cityIds),
|
||||||
nationIds: mergeIds(left.nationIds, right.nationIds),
|
nationIds: mergeIds(left.nationIds, right.nationIds),
|
||||||
|
mapGeneralIds: mergeIds(left.mapGeneralIds ?? left.generalIds, right.mapGeneralIds ?? right.generalIds),
|
||||||
|
mapCityIds: mergeIds(left.mapCityIds ?? left.cityIds, right.mapCityIds ?? right.cityIds),
|
||||||
|
mapNationIds: mergeIds(left.mapNationIds ?? left.nationIds, right.mapNationIds ?? right.nationIds),
|
||||||
|
frontStatusGeneralIds: mergeIds(
|
||||||
|
left.frontStatusGeneralIds ?? (left.contactsChanged ? left.generalIds : []),
|
||||||
|
right.frontStatusGeneralIds ?? (right.contactsChanged ? right.generalIds : [])
|
||||||
|
),
|
||||||
|
frontStatusNationIds: mergeIds(
|
||||||
|
left.frontStatusNationIds ?? left.nationIds,
|
||||||
|
right.frontStatusNationIds ?? right.nationIds
|
||||||
|
),
|
||||||
|
frontStatusActorIds: mergeIds(left.frontStatusActorIds ?? [], right.frontStatusActorIds ?? []),
|
||||||
|
lobbyGeneralIds: mergeIds(left.lobbyGeneralIds ?? left.generalIds, right.lobbyGeneralIds ?? right.generalIds),
|
||||||
reservedGeneralIds: mergeIds(left.reservedGeneralIds, right.reservedGeneralIds),
|
reservedGeneralIds: mergeIds(left.reservedGeneralIds, right.reservedGeneralIds),
|
||||||
recordGeneralIds: mergeIds(left.recordGeneralIds, right.recordGeneralIds),
|
recordGeneralIds: mergeIds(left.recordGeneralIds, right.recordGeneralIds),
|
||||||
worldChanged: left.worldChanged || right.worldChanged,
|
worldChanged: left.worldChanged || right.worldChanged,
|
||||||
globalRecordsChanged: left.globalRecordsChanged || right.globalRecordsChanged,
|
globalRecordsChanged: left.globalRecordsChanged || right.globalRecordsChanged,
|
||||||
worldHistoryChanged: left.worldHistoryChanged || right.worldHistoryChanged,
|
worldHistoryChanged: left.worldHistoryChanged || right.worldHistoryChanged,
|
||||||
contactsChanged: left.contactsChanged || right.contactsChanged,
|
contactsChanged: left.contactsChanged || right.contactsChanged,
|
||||||
|
frontStatusChanged: Boolean(left.frontStatusChanged) || Boolean(right.frontStatusChanged),
|
||||||
|
lobbyChanged: (left.lobbyChanged ?? left.contactsChanged) || (right.lobbyChanged ?? right.contactsChanged),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const hasRealtimeReadModelChanges = (changes: RealtimeReadModelChanges): boolean =>
|
export const hasRealtimeReadModelChanges = (changes: RealtimeReadModelChanges): boolean =>
|
||||||
changes.generalIds.length > 0 ||
|
changes.generalIds.length > 0 ||
|
||||||
changes.cityIds.length > 0 ||
|
changes.cityIds.length > 0 ||
|
||||||
changes.nationIds.length > 0 ||
|
changes.nationIds.length > 0 ||
|
||||||
|
(changes.frontStatusActorIds?.length ?? 0) > 0 ||
|
||||||
changes.reservedGeneralIds.length > 0 ||
|
changes.reservedGeneralIds.length > 0 ||
|
||||||
changes.recordGeneralIds.length > 0 ||
|
changes.recordGeneralIds.length > 0 ||
|
||||||
changes.worldChanged ||
|
changes.worldChanged ||
|
||||||
changes.globalRecordsChanged ||
|
changes.globalRecordsChanged ||
|
||||||
changes.worldHistoryChanged ||
|
changes.worldHistoryChanged ||
|
||||||
changes.contactsChanged;
|
changes.contactsChanged ||
|
||||||
|
Boolean(changes.frontStatusChanged) ||
|
||||||
|
Boolean(changes.lobbyChanged);
|
||||||
|
|
||||||
export interface TurnCompletedEvent {
|
export interface TurnCompletedEvent {
|
||||||
type: 'turnCompleted';
|
type: 'turnCompleted';
|
||||||
|
|||||||
Reference in New Issue
Block a user