feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다

게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
2026-08-23 13:16:07 +00:00
parent f858806295
commit 7bc3213b03
34 changed files with 1964 additions and 9 deletions
+14
View File
@@ -3,6 +3,8 @@ import {
createGamePostgresConnector,
GamePrisma,
writeReadModelChangeJournal,
enqueuePrivateMessageWebPush,
enqueueWebPushOutboxEvents,
type InputJsonValue,
type ReadModelJournalWriteResult,
type TurnEngineCityUpdateInput,
@@ -50,6 +52,7 @@ import { buildPersistedRankRows } from './rankData.js';
import { persistUnificationFinalization } from './unificationPersistence.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import { persistYearbookSnapshot } from './yearbookPersistence.js';
import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -1039,6 +1042,7 @@ export const createDatabaseTurnHooks = async (
const readModelBaseline = createRealtimeReadModelBaseline(world);
let worldReadModelBaseline = createWorldReadModelSignature(world);
let persistedTickSeconds = world.getState().tickSeconds;
let webPushTurnBaseline = captureWebPushTurnBaseline(world, options?.reservedTurns);
const committedReceipts = new Map<bigint, CommittedReadModelChangeReceipt>();
const enqueueCommittedReceipt = (
@@ -1109,6 +1113,13 @@ export const createDatabaseTurnHooks = async (
const persistedReservedTurnChanges = reservedTurnChanges
? excludeDeletedReservedTurnQueues(reservedTurnChanges, deletedGenerals, deletedNations)
: undefined;
const nextWebPushTurnBaseline = captureWebPushTurnBaseline(world, options?.reservedTurns);
const webPushEvents = buildTurnWebPushEvents({
before: webPushTurnBaseline,
after: nextWebPushTurnBaseline,
changes,
...(persistedReservedTurnChanges ? { reservedTurnChanges: persistedReservedTurnChanges } : {}),
});
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
currentYear: state.currentYear,
@@ -1597,6 +1608,7 @@ export const createDatabaseTurnHooks = async (
if (!id) {
throw new Error('Failed to persist turn message.');
}
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
return id;
},
@@ -1657,6 +1669,7 @@ export const createDatabaseTurnHooks = async (
journal.mark('betting');
}
const journalWrite = await writeReadModelChangeJournal(prisma, journal.snapshot());
await enqueueWebPushOutboxEvents(prisma, webPushEvents);
return { readModelChanges, journalWrite, worldReadModelSignature };
};
const persisted = transaction
@@ -1671,6 +1684,7 @@ export const createDatabaseTurnHooks = async (
applyRealtimeReadModelBaseline(readModelBaseline, changes);
worldReadModelBaseline = persisted.worldReadModelSignature;
persistedTickSeconds = state.tickSeconds;
webPushTurnBaseline = nextWebPushTurnBaseline;
},
readModelChanges: persisted.readModelChanges,
journalWrite: persisted.journalWrite,
@@ -189,6 +189,13 @@ export class InMemoryReservedTurnStore {
return this.captureState();
}
inspectGeneralTurnActivity(): Array<[number, boolean]> {
return Array.from(this.generalTurns, ([generalId, turns]) => [
generalId,
turns.some((turn) => turn.action !== DEFAULT_TURN_ACTION || Object.keys(turn.args).length > 0),
]);
}
async loadAll(): Promise<void> {
const [generalRows, nationRows] = await Promise.all([
this.prisma.generalTurn.findMany(),
@@ -1,5 +1,5 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
@@ -130,6 +130,7 @@ const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: M
`;
const id = rows[0]?.id;
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
await enqueuePrivateMessageWebPush(transaction, draft, id);
return id;
};
+131
View File
@@ -0,0 +1,131 @@
import { asRecord } from '@sammo-ts/common';
import type { WebPushOutboxEventInput } from '@sammo-ts/infra';
import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
interface GeneralNotificationState {
userId: string | null;
nationId: number;
crew: number;
deathCrew: number;
autorunLimit: number | null;
}
export interface WebPushTurnBaseline {
serverId: string;
year: number;
month: number;
turnTick: string;
generals: Map<number, GeneralNotificationState>;
hasReservedTurns: Map<number, boolean>;
}
const readFiniteNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const readDeathCrew = (meta: Record<string, unknown>): number =>
readFiniteNumber(meta.rank_deathcrew) ?? readFiniteNumber(meta.deathcrew) ?? 0;
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
export const captureWebPushTurnBaseline = (
world: InMemoryTurnWorld,
reservedTurns?: InMemoryReservedTurnStore
): WebPushTurnBaseline => {
const state = world.getState();
const meta = asRecord(state.meta);
return {
serverId: typeof meta.serverId === 'string' && meta.serverId ? meta.serverId : 'active-season',
year: state.currentYear,
month: state.currentMonth,
turnTick: String(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
generals: new Map(
world.listGenerals().map((general) => {
const generalMeta = asRecord(general.meta);
return [
general.id,
{
userId: general.userId ?? null,
nationId: general.nationId,
crew: general.crew,
deathCrew: readDeathCrew(generalMeta),
autorunLimit: readFiniteNumber(generalMeta.autorun_limit),
},
];
})
),
hasReservedTurns: new Map(reservedTurns?.inspectGeneralTurnActivity() ?? []),
};
};
export const buildTurnWebPushEvents = (input: {
before: WebPushTurnBaseline;
after: WebPushTurnBaseline;
changes: Pick<TurnWorldChanges, 'deletedNationSnapshots'>;
reservedTurnChanges?: Pick<ReservedTurnChanges, 'generalIds'>;
}): WebPushOutboxEventInput[] => {
const events: WebPushOutboxEventInput[] = [];
const { before, after } = input;
for (const [generalId, current] of after.generals) {
const previous = before.generals.get(generalId);
if (!previous?.userId || previous.userId !== current.userId) continue;
if (previous.crew > 0 && current.crew <= 0 && current.deathCrew > previous.deathCrew) {
events.push({
eventId: `${after.serverId}:troop-annihilated:${generalId}:${current.deathCrew}`,
eventType: 'TROOP_ANNIHILATED',
userIds: [current.userId],
});
}
}
const dirtyReservedGeneralIds = new Set(input.reservedTurnChanges?.generalIds ?? []);
for (const generalId of dirtyReservedGeneralIds) {
if (!before.hasReservedTurns.get(generalId) || after.hasReservedTurns.get(generalId)) continue;
const userId = after.generals.get(generalId)?.userId ?? before.generals.get(generalId)?.userId;
if (!userId) continue;
events.push({
eventId: `${after.serverId}:reserved-turns-ended:${generalId}:${after.turnTick}`,
eventType: 'RESERVED_TURNS_ENDED',
userIds: [userId],
});
}
const beforeYearMonth = joinYearMonth(before.year, before.month);
const afterYearMonth = joinYearMonth(after.year, after.month);
if (afterYearMonth > beforeYearMonth) {
events.push({
eventId: `${after.serverId}:calendar:${after.year}:${after.month}`,
eventType: 'TARGET_DATE_REACHED',
year: after.year,
month: after.month,
});
for (const [generalId, current] of after.generals) {
const previous = before.generals.get(generalId);
const limit = current.autorunLimit ?? previous?.autorunLimit;
if (!current.userId || limit == null) continue;
if (beforeYearMonth < limit && afterYearMonth >= limit) {
events.push({
eventId: `${after.serverId}:autorun-ended:${generalId}:${limit}`,
eventType: 'AUTONOMOUS_ACTION_ENDED',
userIds: [current.userId],
});
}
}
}
for (const snapshot of input.changes.deletedNationSnapshots) {
const userIds = snapshot.generalIds
.map((generalId) => before.generals.get(generalId)?.userId)
.filter((userId): userId is string => Boolean(userId));
if (userIds.length === 0) continue;
events.push({
eventId: `${after.serverId}:nation-destroyed:${snapshot.nation.id}:${snapshot.removedAt.toISOString()}`,
eventType: 'NATION_DESTROYED',
userIds,
});
}
return events;
};
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { buildTurnWebPushEvents, type WebPushTurnBaseline } from '../src/turn/webPushEvents.js';
const general = (
userId: string | null,
options: { nationId?: number; crew?: number; deathCrew?: number; autorunLimit?: number | null } = {}
) => ({
userId,
nationId: options.nationId ?? 1,
crew: options.crew ?? 100,
deathCrew: options.deathCrew ?? 0,
autorunLimit: options.autorunLimit ?? null,
});
const baseline = (input: Partial<WebPushTurnBaseline> = {}): WebPushTurnBaseline => ({
serverId: 'season-1',
year: 200,
month: 1,
turnTick: '100',
generals: new Map(),
hasReservedTurns: new Map(),
...input,
});
describe('turn web push event projection', () => {
it('emits annihilation only when battle casualty totals also increase', () => {
const before = baseline({ generals: new Map([[1, general('user-1', { crew: 500, deathCrew: 10 })]]) });
const battleAfter = baseline({
generals: new Map([[1, general('user-1', { crew: 0, deathCrew: 510 })]]),
turnTick: '101',
});
const disbandAfter = baseline({
generals: new Map([[1, general('user-1', { crew: 0, deathCrew: 10 })]]),
turnTick: '101',
});
expect(buildTurnWebPushEvents({ before, after: battleAfter, changes: { deletedNationSnapshots: [] } })).toEqual(
[expect.objectContaining({ eventType: 'TROOP_ANNIHILATED', userIds: ['user-1'] })]
);
expect(
buildTurnWebPushEvents({ before, after: disbandAfter, changes: { deletedNationSnapshots: [] } })
).toEqual([]);
});
it('emits reserved-turn completion only for a dirty queue that consumed its last command', () => {
const before = baseline({
generals: new Map([[1, general('user-1')]]),
hasReservedTurns: new Map([[1, true]]),
});
const after = baseline({
generals: new Map([[1, general('user-1')]]),
hasReservedTurns: new Map([[1, false]]),
turnTick: '102',
});
const events = buildTurnWebPushEvents({
before,
after,
changes: { deletedNationSnapshots: [] },
reservedTurnChanges: { generalIds: [1] },
});
expect(events).toEqual([expect.objectContaining({ eventType: 'RESERVED_TURNS_ENDED', userIds: ['user-1'] })]);
});
it('emits calendar and autonomous-expiry events at the exclusive limit month', () => {
const before = baseline({
year: 200,
month: 1,
generals: new Map([[1, general('user-1', { autorunLimit: 2401 })]]),
});
const after = baseline({
year: 200,
month: 2,
generals: new Map([[1, general('user-1', { autorunLimit: 2401 })]]),
turnTick: '103',
});
expect(
buildTurnWebPushEvents({ before, after, changes: { deletedNationSnapshots: [] } }).map(
(event) => event.eventType
)
).toEqual(['TARGET_DATE_REACHED', 'AUTONOMOUS_ACTION_ENDED']);
});
it('targets the users who belonged to a destroyed nation before its removal', () => {
const before = baseline({
generals: new Map([
[1, general('user-1', { nationId: 7 })],
[2, general(null, { nationId: 7 })],
[3, general('user-3', { nationId: 7 })],
]),
});
const after = baseline({
generals: new Map([
[1, general('user-1', { nationId: 0 })],
[3, general('user-3', { nationId: 0 })],
]),
turnTick: '104',
});
const events = buildTurnWebPushEvents({
before,
after,
changes: {
deletedNationSnapshots: [
{
nation: { id: 7 },
generalIds: [1, 2, 3],
removedAt: new Date('0200-01-01T00:00:00.000Z'),
} as never,
],
},
});
expect(events).toEqual([
expect.objectContaining({ eventType: 'NATION_DESTROYED', userIds: ['user-1', 'user-3'] }),
]);
});
});