diff --git a/app/game-api/src/realtime/publisher.ts b/app/game-api/src/realtime/publisher.ts index 3607d75f..1bc008ca 100644 --- a/app/game-api/src/realtime/publisher.ts +++ b/app/game-api/src/realtime/publisher.ts @@ -1,5 +1,10 @@ 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 채널로 송신한다. export const publishRealtimeEvent = async ( @@ -10,3 +15,18 @@ export const publishRealtimeEvent = async ( const channel = buildGameEventChannel(profileName); await redis.publish(channel, JSON.stringify(event)); }; + +export const publishRealtimeReadModelChanges = async ( + redis: RedisConnector['client'], + profileName: string, + changes: RealtimeReadModelChanges +): Promise => { + const revision = await redis.incr(buildGameReadModelRevisionKey(profileName)); + await publishRealtimeEvent(redis, profileName, { + type: 'readModelChanged', + at: new Date().toISOString(), + changes, + revision, + }); + return revision; +}; diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 2905b466..c27816a2 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; 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 { ITEM_KEYS, @@ -17,8 +17,24 @@ import { } from '@sammo-ts/logic'; import { authedProcedure, router } from '../../trpc.js'; +import type { GameApiContext } from '../../context.js'; import { getMyGeneral } from '../shared/general.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 => { + 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 => { 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 }); } + await publishFrontStatusChange(ctx, { generalId: general.id }); return { ok: true, wonLottery: rewardResult.awardedUnique }; }), addComment: authedProcedure @@ -617,6 +634,7 @@ export const voteRouter = router({ ) `); + await publishFrontStatusChange(ctx, { global: true }); return { ok: true }; }), updatePoll: adminProcedure @@ -713,6 +731,9 @@ export const voteRouter = router({ WHERE id = ${input.voteId} `); + if (input.title !== undefined || endAt !== undefined) { + await publishFrontStatusChange(ctx, { global: true }); + } return { ok: true }; }), closePoll: adminProcedure @@ -727,6 +748,7 @@ export const voteRouter = router({ if (!rows[0]?.id) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } + await publishFrontStatusChange(ctx, { global: true }); return { ok: true }; }), getAdminStatus: adminProcedure.query(async () => ({ ok: true })), diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index 75680101..502795ab 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -104,6 +104,8 @@ const buildContext = (options: { generalId: general?.id ?? 0, 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 text = sqlText(query); if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) { @@ -177,7 +179,10 @@ const buildContext = (options: { ); const context: GameApiContext = { 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, battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, @@ -189,7 +194,7 @@ const buildContext = (options: { flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; - return { context, requestCommand, queryRaw, db }; + return { context, requestCommand, queryRaw, db, redisIncr, redisPublish }; }; describe('vote router actor and permission boundaries', () => { @@ -216,6 +221,34 @@ describe('vote router actor and permission boundaries', () => { 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 () => { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index fd7fc736..4d88acae 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -19,8 +19,10 @@ import { LogFormat, LogScope, sendMessage, + type City, type LogEntryDraft, type MessageRecordDraft, + type Nation, } from '@sammo-ts/logic'; import { asRecord, type RealtimeReadModelChanges } from '@sammo-ts/common'; @@ -33,6 +35,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; +import type { TurnGeneral } from './types.js'; import { buildPersistedRankRows } from './rankData.js'; import { persistUnificationFinalization } from './unificationPersistence.js'; import { buildOldNationArchiveData } from './oldNationArchive.js'; @@ -47,10 +50,124 @@ export interface DatabaseTurnHooks { const uniqueSortedIds = (values: Iterable): number[] => [...new Set(values)].filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => left - right); -export const summarizeRealtimeReadModelChanges = ( - changes: TurnWorldChanges, - reservedTurnChanges?: ReservedTurnChanges -): RealtimeReadModelChanges => { +export type ReadModelSignatures = { + content: string; + map: string; + contacts: string; + frontStatus: string; + frontStatusGlobal: string; + lobbyCount: string; + lobbyPersonal: string; +}; + +export interface RealtimeReadModelBaseline { + generals: Map; + cities: Map; + nations: Map; +} + +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) + .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, + final: ReadonlyMap, + projection: keyof ReadModelSignatures +): number[] => uniqueSortedIds(candidateIds.filter((id) => baseline.get(id)?.[projection] !== final.get(id)?.[projection])); + +const buildFinalSignatures = ( + entities: readonly Entity[], + project: (entity: Entity) => ReadModelSignatures +): Map => new Map(entities.map((entity) => [entity.id, project(entity)])); + +export const applyRealtimeReadModelBaseline = ( + baseline: RealtimeReadModelBaseline, + changes: TurnWorldChanges +): void => { + const apply = ( + target: Map, + candidateIds: readonly number[], + final: ReadonlyMap + ) => { + for (const id of candidateIds) { + const next = final.get(id); + if (next) target.set(id, next); + else target.delete(id); + } + }; const generalIds = uniqueSortedIds([ ...changes.generals.map((general) => general.id), ...changes.createdGenerals.map((general) => general.id), @@ -64,6 +181,81 @@ export const summarizeRealtimeReadModelChanges = ( ...changes.deletedNations, ...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( reservedTurnChanges ? [ @@ -88,34 +280,44 @@ export const summarizeRealtimeReadModelChanges = ( const worldHistoryChanged = changes.logs.some( (entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY ); - const contactsChanged = - changes.createdGenerals.length > 0 || - changes.deletedGenerals.length > 0 || - changes.createdNations.length > 0 || - changes.deletedNations.length > 0 || - changes.lifecycleEvents.some((event) => { - const after = event.after; - const beforePermission = asRecord(event.before.meta).permission; - const afterPermission = after ? asRecord(after.meta).permission : undefined; - return ( - !after || - event.before.name !== after.name || - event.before.nationId !== after.nationId || - event.before.officerLevel !== after.officerLevel || - beforePermission !== afterPermission - ); - }); + const contactsChanged = baseline + ? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'contacts').length > 0 || + changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'contacts').length > 0 + : changes.createdGenerals.length > 0 || + changes.deletedGenerals.length > 0 || + changes.createdNations.length > 0 || + changes.deletedNations.length > 0 || + changes.lifecycleEvents.some((event) => { + const after = event.after; + const beforePermission = asRecord(event.before.meta).permission; + const afterPermission = after ? asRecord(after.meta).permission : undefined; + return ( + !after || + event.before.name !== after.name || + event.before.nationId !== after.nationId || + event.before.officerLevel !== after.officerLevel || + beforePermission !== afterPermission + ); + }); return { generalIds, cityIds, nationIds, + mapGeneralIds, + mapCityIds, + mapNationIds, + frontStatusGeneralIds, + frontStatusNationIds, + lobbyGeneralIds, reservedGeneralIds, recordGeneralIds, worldChanged: false, globalRecordsChanged, worldHistoryChanged, contactsChanged, + frontStatusChanged, + lobbyChanged, }; }; @@ -665,6 +867,7 @@ export const createDatabaseTurnHooks = async ( await connector.connect(); const prisma = connector.prisma; let committedReadModelChanges: RealtimeReadModelChanges | null = null; + const readModelBaseline = createRealtimeReadModelBaseline(world); const persistChanges = async ( transaction?: GamePrisma.TransactionClient, @@ -1136,14 +1339,20 @@ export const createDatabaseTurnHooks = async ( ); } + const readModelChanges = summarizeRealtimeReadModelChanges( + changes, + persistedReservedTurnChanges, + readModelBaseline + ); return { acknowledge: () => { world.acknowledgeDirtyState(changes); if (options?.reservedTurns && reservedTurnChanges) { options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges); } + applyRealtimeReadModelBaseline(readModelBaseline, changes); }, - readModelChanges: summarizeRealtimeReadModelChanges(changes, persistedReservedTurnChanges), + readModelChanges, }; }; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 666376f9..9968b538 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -746,7 +746,11 @@ const createTurnDaemonRuntimeWithLease = async ( const revisionKey = buildGameReadModelRevisionKey(options.profileName ?? options.profile); const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName ?? options.profile); 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); } return redisClient.incr(revisionKey); @@ -787,6 +791,9 @@ const createTurnDaemonRuntimeWithLease = async ( publishCommandEvents: async (result) => { try { const changes = takeCommittedReadModelChanges?.(); + if (changes && result.type === 'shiftSchedule' && result.ok) { + changes.lobbyChanged = true; + } if (changes && hasRealtimeReadModelChanges(changes)) { const revision = await publishCommittedChanges(changes); if (revision !== undefined) { diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index 426d025d..d382c54b 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; 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 { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js'; @@ -52,12 +57,212 @@ describe('summarizeRealtimeReadModelChanges', () => { generalIds: [7, 8, 9], cityIds: [4], nationIds: [3], + mapGeneralIds: [7, 8, 9], + mapCityIds: [4], + mapNationIds: [3], + frontStatusGeneralIds: [7, 8, 9], + frontStatusNationIds: [3], + lobbyGeneralIds: [7, 8, 9], reservedGeneralIds: [7, 8, 9], recordGeneralIds: [7], worldChanged: false, globalRecordsChanged: true, worldHistoryChanged: 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, }); }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 56beec4d..eae4b2cf 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -601,6 +601,15 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl generalIds: [7], cityIds: [], nationIds: [], + mapGeneralIds: [], + mapCityIds: [], + mapNationIds: [], + frontStatusGeneralIds: [], + frontStatusNationIds: [], + frontStatusActorIds: [], + frontStatusChanged: false, + lobbyGeneralIds: [], + lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], 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('부드럽게갱신된장수'); const changedOperations = state.operations.slice(operationsBeforeChangedBurst); 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.arrayContaining([ 'lobby.info', + 'world.getMap', 'messages.getRecent', 'messages.getContacts', '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 probe = ( window as unknown as { @@ -697,6 +741,15 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl generalIds: [7], cityIds: [], nationIds: [], + mapGeneralIds: [], + mapCityIds: [], + mapNationIds: [], + frontStatusGeneralIds: [], + frontStatusNationIds: [], + frontStatusActorIds: [], + frontStatusChanged: false, + lobbyGeneralIds: [], + lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, diff --git a/app/game-frontend/src/utils/dashboardReadModel.ts b/app/game-frontend/src/utils/dashboardReadModel.ts index 63bf4d16..af7f09f2 100644 --- a/app/game-frontend/src/utils/dashboardReadModel.ts +++ b/app/game-frontend/src/utils/dashboardReadModel.ts @@ -31,16 +31,31 @@ export const resolveDashboardRefreshPlan = ( const ownGeneralChanged = contains(changes.generalIds, identity.generalId); const ownCityChanged = contains(changes.cityIds, identity.cityId); 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 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 { context: entityContextChanged, - lobby: changes.worldChanged || changes.contactsChanged, - map: changes.worldChanged || worldEntitiesChanged || ownGeneralChanged, - commands: changes.worldChanged || worldEntitiesChanged || ownGeneralChanged, + lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged, + map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged, + commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged, contacts: changes.contactsChanged, - boardAccess: entityContextChanged, + boardAccess: ownGeneralChanged || ownNationChanged, reservedTurns: contains(changes.reservedGeneralIds, identity.generalId), records: changes.globalRecordsChanged || @@ -48,7 +63,11 @@ export const resolveDashboardRefreshPlan = ( contains(changes.recordGeneralIds, identity.generalId), // lastTurnTime is intentionally excluded. This slice contains the // nation notice/vote/presence model and only follows related changes. - frontStatus: changes.contactsChanged || ownNationChanged, + frontStatus: + Boolean(changes.frontStatusChanged) || + frontStatusGeneralChanged || + ownFrontStatusNationChanged || + ownFrontStatusActorChanged, }; }; diff --git a/app/game-frontend/test/dashboardReadModel.test.ts b/app/game-frontend/test/dashboardReadModel.test.ts index c4a09999..c085bc06 100644 --- a/app/game-frontend/test/dashboardReadModel.test.ts +++ b/app/game-frontend/test/dashboardReadModel.test.ts @@ -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 }), { context: true, lobby: false, - map: true, + map: false, commands: true, contacts: false, 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 () => { let nowMs = 0; let nextTimerId = 1; diff --git a/packages/common/src/realtime/types.ts b/packages/common/src/realtime/types.ts index 4fe201ed..d83dac0a 100644 --- a/packages/common/src/realtime/types.ts +++ b/packages/common/src/realtime/types.ts @@ -9,24 +9,48 @@ export interface RealtimeReadModelChanges { generalIds: number[]; cityIds: 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[]; recordGeneralIds: number[]; worldChanged: boolean; globalRecordsChanged: boolean; worldHistoryChanged: boolean; contactsChanged: boolean; + /** A global front-status source such as the active survey changed. */ + frontStatusChanged?: boolean; + lobbyChanged?: boolean; } export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({ generalIds: [], cityIds: [], nationIds: [], + mapGeneralIds: [], + mapCityIds: [], + mapNationIds: [], + frontStatusGeneralIds: [], + frontStatusNationIds: [], + frontStatusActorIds: [], + lobbyGeneralIds: [], reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, + frontStatusChanged: false, + lobbyChanged: false, }); const mergeIds = (left: readonly number[], right: readonly number[]): number[] => @@ -39,24 +63,42 @@ export const mergeRealtimeReadModelChanges = ( generalIds: mergeIds(left.generalIds, right.generalIds), cityIds: mergeIds(left.cityIds, right.cityIds), 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), recordGeneralIds: mergeIds(left.recordGeneralIds, right.recordGeneralIds), worldChanged: left.worldChanged || right.worldChanged, globalRecordsChanged: left.globalRecordsChanged || right.globalRecordsChanged, worldHistoryChanged: left.worldHistoryChanged || right.worldHistoryChanged, 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 => changes.generalIds.length > 0 || changes.cityIds.length > 0 || changes.nationIds.length > 0 || + (changes.frontStatusActorIds?.length ?? 0) > 0 || changes.reservedGeneralIds.length > 0 || changes.recordGeneralIds.length > 0 || changes.worldChanged || changes.globalRecordsChanged || changes.worldHistoryChanged || - changes.contactsChanged; + changes.contactsChanged || + Boolean(changes.frontStatusChanged) || + Boolean(changes.lobbyChanged); export interface TurnCompletedEvent { type: 'turnCompleted';