fix(game): sync diplomatic responses into turn world

This commit is contained in:
2026-09-03 18:18:22 +00:00
parent 03e47feef1
commit 62006fb2d4
7 changed files with 343 additions and 54 deletions
@@ -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 = {