fix(game-api): separate engine message responses

This commit is contained in:
2026-09-03 17:57:17 +00:00
parent 1cb66f76fb
commit 03e47feef1
2 changed files with 58 additions and 23 deletions
+53 -22
View File
@@ -1,6 +1,9 @@
import { randomUUID } from 'node:crypto';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord, ChangeJournal } from '@sammo-ts/common';
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
@@ -9,7 +12,9 @@ import {
accessLimitAuthedInputProcedure, accessLimitAuthedInputProcedure,
accessWallAuthedInputProcedure, accessWallAuthedInputProcedure,
authedProcedure, authedProcedure,
engineAuthedProcedure,
router, router,
scopeApiInputEventRequestId,
wallAuthedProcedure, wallAuthedProcedure,
} from '../../trpc.js'; } from '../../trpc.js';
import { import {
@@ -32,6 +37,7 @@ import {
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js'; import { resolveNationPermission } from '../nation/shared.js';
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
import { executeInputEvent } from '../../inputEventBoundary.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -315,7 +321,7 @@ export const messagesRouter = router({
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]); markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
return { ok: true, deletedIds }; return { ok: true, deletedIds };
}), }),
respond: authedProcedure respond: engineAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -346,29 +352,54 @@ export const messagesRouter = router({
} }
return { result: commandResult.ok, reason: commandResult.reason }; return { result: commandResult.ok, reason: commandResult.reason };
} }
const result = await respondToDiplomaticMessage({ const ownsChangeJournal = !ctx.changeJournal;
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
let journalPersisted = false;
const response = await executeInputEvent({
db: ctx.db, db: ctx.db,
actor: general, requestId: scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), 'messages.respond.diplomatic', 0),
messageId: input.messageId, eventType: 'messages.respond.diplomatic',
response: input.response, payload: input,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const transactionContext = { ...ctx, db: transaction, changeJournal };
const transactionGeneral = ownsChangeJournal
? await getOwnedGeneral(transactionContext, input.generalId)
: general;
const result = await respondToDiplomaticMessage({
db: transaction,
actor: transactionGeneral,
messageId: input.messageId,
response: input.response,
});
markMessageMailboxes(transactionContext, result.affectedMailboxes);
for (const generalId of result.affectedGeneralRecordIds) {
changeJournal.mark('records.general', generalId);
}
for (const nationId of result.affectedNationIds) {
changeJournal.mark('nation.content', nationId);
}
for (const cityId of result.affectedCityIds) {
changeJournal.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
changeJournal.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
changeJournal.mark('dashboard.global');
}
if (ownsChangeJournal) {
journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
}
return { result: result.result, reason: result.reason };
},
}); });
markMessageMailboxes(ctx, result.affectedMailboxes); if (journalPersisted) {
for (const generalId of result.affectedGeneralRecordIds) { ctx.readModelOutbox?.wake();
ctx.changeJournal?.mark('records.general', generalId);
} }
for (const nationId of result.affectedNationIds) { return response;
ctx.changeJournal?.mark('nation.content', nationId);
}
for (const cityId of result.affectedCityIds) {
ctx.changeJournal?.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('dashboard.global');
}
return { result: result.result, reason: result.reason };
}), }),
getOld: authedProcedure getOld: authedProcedure
.input( .input(
+5 -1
View File
@@ -801,8 +801,11 @@ describe('messages router missing-flow compatibility', () => {
action, action,
reason: 'success', reason: 'success',
})); }));
const transaction = vi.fn(async () => {
throw new Error('ENGINE message response must not enter the API input-event transaction.');
});
const { caller } = buildContext( const { caller } = buildContext(
{ $queryRaw: vi.fn(async () => [messageRow]) }, { $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction },
{ turnDaemon: { requestCommand } } { turnDaemon: { requestCommand } }
); );
@@ -822,6 +825,7 @@ describe('messages router missing-flow compatibility', () => {
response: true, response: true,
}) })
); );
expect(transaction).not.toHaveBeenCalled();
} }
); );