fix(game): sync diplomatic responses into turn world
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { asRecord, ChangeJournal } from '@sammo-ts/common';
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
authedProcedure,
|
||||
engineAuthedProcedure,
|
||||
router,
|
||||
scopeApiInputEventRequestId,
|
||||
wallAuthedProcedure,
|
||||
} from '../../trpc.js';
|
||||
import {
|
||||
@@ -357,7 +354,7 @@ export const messagesRouter = router({
|
||||
let journalPersisted = false;
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
requestId: scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), 'messages.respond.diplomatic', 0),
|
||||
requestId: `messages.respond.diplomatic:${input.messageId}`,
|
||||
eventType: 'messages.respond.diplomatic',
|
||||
payload: input,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
@@ -393,13 +390,39 @@ export const messagesRouter = router({
|
||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
||||
);
|
||||
}
|
||||
return { result: result.result, reason: result.reason };
|
||||
return {
|
||||
result: result.result,
|
||||
reason: result.reason,
|
||||
affectedNationIds: result.affectedNationIds,
|
||||
affectedCityIds: result.affectedCityIds,
|
||||
};
|
||||
},
|
||||
});
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
return response;
|
||||
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const synchronized = await ctx.turnDaemon.requestCommand({
|
||||
type: 'syncDiplomaticResponse',
|
||||
userId: ctx.auth.user.id,
|
||||
generalId: general.id,
|
||||
messageId: input.messageId,
|
||||
nationIds: response.affectedNationIds,
|
||||
cityIds: response.affectedCityIds,
|
||||
});
|
||||
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
|
||||
const synchronizationReason =
|
||||
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { result: response.result, reason: response.reason };
|
||||
}),
|
||||
getOld: authedProcedure
|
||||
.input(
|
||||
|
||||
@@ -959,6 +959,15 @@ describe('messages router missing-flow compatibility', () => {
|
||||
const messageActionUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const cityUpdate = vi.fn(async () => ({}));
|
||||
const changeJournal = new ChangeJournal();
|
||||
const requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({
|
||||
type: 'syncDiplomaticResponse' as const,
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
messageId: command.messageId,
|
||||
nations: 2,
|
||||
diplomacy: 2,
|
||||
cities: 0,
|
||||
}));
|
||||
const { caller } = buildContext(
|
||||
{
|
||||
general: {
|
||||
@@ -1049,7 +1058,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
messageAction: { updateMany: messageActionUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
},
|
||||
{ changeJournal }
|
||||
{ changeJournal, turnDaemon: { requestCommand } }
|
||||
);
|
||||
return {
|
||||
caller,
|
||||
@@ -1062,6 +1071,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
messageUpdateMany,
|
||||
cityUpdate,
|
||||
changeJournal,
|
||||
requestCommand,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1075,6 +1085,14 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: true, reason: 'success' });
|
||||
expect(setup.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'syncDiplomaticResponse',
|
||||
userId: auth.user.id,
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
nationIds: [1, 2],
|
||||
cityIds: [],
|
||||
});
|
||||
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(setup.nationUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -131,6 +131,15 @@ const zMessageRespond = z.object({
|
||||
response: z.boolean(),
|
||||
});
|
||||
|
||||
const zSyncDiplomaticResponse = z.object({
|
||||
type: z.literal('syncDiplomaticResponse'),
|
||||
userId: z.string().min(1),
|
||||
generalId: z.number().int().positive(),
|
||||
messageId: z.number().int().positive(),
|
||||
nationIds: z.array(z.number().int().positive()).max(4),
|
||||
cityIds: z.array(z.number().int().positive()).max(256),
|
||||
});
|
||||
|
||||
const zVacation = z.object({
|
||||
type: z.literal('vacation'),
|
||||
userId: z.string().min(1),
|
||||
@@ -609,6 +618,14 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope)
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSyncDiplomaticResponse: CommandNormalizer<'syncDiplomaticResponse'> = (envelope) => {
|
||||
const command = parseWith(zSyncDiplomaticResponse, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
|
||||
const command = parseWith(zVacation, envelope.command);
|
||||
if (!command) {
|
||||
@@ -859,6 +876,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
buildNationCandidate: normalizeBuildNationCandidate,
|
||||
instantRetreat: normalizeInstantRetreat,
|
||||
messageRespond: normalizeMessageRespond,
|
||||
syncDiplomaticResponse: normalizeSyncDiplomaticResponse,
|
||||
vacation: normalizeVacation,
|
||||
setMySetting: normalizeSetMySetting,
|
||||
dropItem: normalizeDropItem,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
normalizeTroopName,
|
||||
resolveTroopSecretPermission,
|
||||
resolveMessageTargetIcon,
|
||||
readDiplomacyMeta,
|
||||
type GeneralActionModule,
|
||||
rollUniqueLottery,
|
||||
type ItemModule,
|
||||
@@ -182,6 +183,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'kick',
|
||||
'appoint',
|
||||
'voteReward',
|
||||
'syncDiplomaticResponse',
|
||||
] as const satisfies readonly TurnDaemonCommand['type'][];
|
||||
|
||||
type ActorBoundGeneralCommandType = (typeof ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST)[number];
|
||||
@@ -1839,6 +1841,72 @@ async function handleMessageRespond(
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSyncDiplomaticResponse(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'syncDiplomaticResponse' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const action = await db.messageAction.findUnique({
|
||||
where: { messageId: command.messageId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (action?.status !== 'RESOLVED') {
|
||||
return {
|
||||
type: 'syncDiplomaticResponse',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
messageId: command.messageId,
|
||||
nations: 0,
|
||||
diplomacy: 0,
|
||||
cities: 0,
|
||||
reason: '해결되지 않은 외교서신은 동기화할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const nationIds = [...new Set(command.nationIds)];
|
||||
const cityIds = [...new Set(command.cityIds)];
|
||||
const [nations, diplomacy, cities] = await Promise.all([
|
||||
db.nation.findMany({ where: { id: { in: nationIds } }, select: { id: true, meta: true } }),
|
||||
nationIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.diplomacy.findMany({
|
||||
where: { srcNationId: { in: nationIds }, destNationId: { in: nationIds } },
|
||||
select: { srcNationId: true, destNationId: true, stateCode: true, term: true, meta: true },
|
||||
}),
|
||||
db.city.findMany({ where: { id: { in: cityIds } }, select: { id: true, frontState: true } }),
|
||||
]);
|
||||
|
||||
for (const nation of nations) {
|
||||
ctx.world.updateNation(nation.id, { meta: asRecord(nation.meta) as Record<string, TriggerValue> });
|
||||
}
|
||||
for (const entry of diplomacy) {
|
||||
const parsedMeta = readDiplomacyMeta(asRecord(entry.meta));
|
||||
ctx.world.applyDiplomacyPatch({
|
||||
srcNationId: entry.srcNationId,
|
||||
destNationId: entry.destNationId,
|
||||
patch: {
|
||||
state: entry.stateCode,
|
||||
term: entry.term,
|
||||
dead: parsedMeta.dead,
|
||||
meta: parsedMeta.meta as Record<string, TriggerValue>,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const city of cities) {
|
||||
ctx.world.updateCity(city.id, { frontState: city.frontState });
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'syncDiplomaticResponse',
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
messageId: command.messageId,
|
||||
nations: nations.length,
|
||||
diplomacy: diplomacy.length,
|
||||
cities: cities.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleVacation(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'vacation' }>
|
||||
@@ -3151,6 +3219,11 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
|
||||
messageRespond: (command) =>
|
||||
handleMessageRespond(ctx, command as Extract<TurnDaemonCommand, { type: 'messageRespond' }>),
|
||||
syncDiplomaticResponse: (command) =>
|
||||
handleSyncDiplomaticResponse(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'syncDiplomaticResponse' }>
|
||||
),
|
||||
vacation: (command) => handleVacation(ctx, command as Extract<TurnDaemonCommand, { type: 'vacation' }>),
|
||||
setMySetting: (command) =>
|
||||
handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
|
||||
|
||||
@@ -46,6 +46,15 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
officerLevel: 4,
|
||||
},
|
||||
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
|
||||
{
|
||||
type: 'syncDiplomaticResponse',
|
||||
requestId: 'syncDiplomaticResponse',
|
||||
userId,
|
||||
generalId: 7,
|
||||
messageId: 31,
|
||||
nationIds: [1, 2],
|
||||
cityIds: [1],
|
||||
},
|
||||
];
|
||||
|
||||
const buildReadOnlyWorld = (ownerUserId: string) => {
|
||||
@@ -181,6 +190,66 @@ describe('authenticated actor-bound command registry and execution', () => {
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the daemon world from the committed diplomatic response before the next turn', async () => {
|
||||
const updateNation = vi.fn();
|
||||
const applyDiplomacyPatch = vi.fn();
|
||||
const updateCity = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: 7, userId: 'old-owner' })),
|
||||
updateNation,
|
||||
applyDiplomacyPatch,
|
||||
updateCity,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const db = {
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
actorUserId: 'old-owner',
|
||||
target: 'ENGINE',
|
||||
eventType: 'syncDiplomaticResponse',
|
||||
})),
|
||||
},
|
||||
messageAction: { findUnique: vi.fn(async () => ({ status: 'RESOLVED' })) },
|
||||
nation: { findMany: vi.fn(async () => [{ id: 1, meta: { policy: 'balanced' } }]) },
|
||||
diplomacy: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ srcNationId: 1, destNationId: 2, stateCode: 7, term: 12, meta: { dead: 3 } },
|
||||
]),
|
||||
},
|
||||
city: { findMany: vi.fn(async () => [{ id: 4, frontState: 2 }]) },
|
||||
};
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
handler.handle(
|
||||
{
|
||||
type: 'syncDiplomaticResponse',
|
||||
requestId: 'syncDiplomaticResponse',
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
messageId: 31,
|
||||
nationIds: [1, 2],
|
||||
cityIds: [4],
|
||||
},
|
||||
{ db: db as never }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
type: 'syncDiplomaticResponse',
|
||||
ok: true,
|
||||
generalId: 7,
|
||||
messageId: 31,
|
||||
nations: 1,
|
||||
diplomacy: 1,
|
||||
cities: 1,
|
||||
});
|
||||
expect(updateNation).toHaveBeenCalledWith(1, { meta: { policy: 'balanced' } });
|
||||
expect(applyDiplomacyPatch).toHaveBeenCalledWith({
|
||||
srcNationId: 1,
|
||||
destNationId: 2,
|
||||
patch: { state: 7, term: 12, dead: 3, meta: {} },
|
||||
});
|
||||
expect(updateCity).toHaveBeenCalledWith(4, { frontState: 2 });
|
||||
});
|
||||
|
||||
it('preserves direct in-memory invocation when no command database is supplied', async () => {
|
||||
const updateGeneral = vi.fn();
|
||||
const world = {
|
||||
|
||||
Reference in New Issue
Block a user