feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다
게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'] }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user