fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다

수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다.

요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
2026-08-24 12:10:57 +00:00
parent 5389f8ed94
commit 630bc29100
74 changed files with 3893 additions and 1141 deletions
@@ -1,8 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { TurnDaemonCommand } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -35,7 +39,8 @@ integration('database command queue', () => {
requestId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue,
actorUserId: 'user-7',
payload: { type: 'vacation', requestId, userId: 'user-7', generalId: 7 } as GamePrisma.InputJsonValue,
},
});
@@ -44,7 +49,7 @@ integration('database command queue', () => {
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
const commands = firstCommands.concat(secondCommands);
expect(commands).toEqual([{ type: 'vacation', requestId, generalId: 7 }]);
expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]);
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
@@ -64,7 +69,13 @@ integration('database command queue', () => {
requestId: expiredId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId: expiredId, generalId: 8 } as GamePrisma.InputJsonValue,
actorUserId: 'user-8',
payload: {
type: 'vacation',
requestId: expiredId,
userId: 'user-8',
generalId: 8,
} as GamePrisma.InputJsonValue,
status: 'PROCESSING',
processingAt: new Date(Date.now() - 120_000),
lockedBy: 'dead-worker',
@@ -74,7 +85,13 @@ integration('database command queue', () => {
requestId: activeId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId: activeId, generalId: 9 } as GamePrisma.InputJsonValue,
actorUserId: 'user-9',
payload: {
type: 'vacation',
requestId: activeId,
userId: 'user-9',
generalId: 9,
} as GamePrisma.InputJsonValue,
status: 'PROCESSING',
processingAt: new Date(),
lockedBy: 'active-worker',
@@ -87,7 +104,7 @@ integration('database command queue', () => {
await queue.initialize();
const commands = await queue.drain();
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, generalId: 8 }]);
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
status: 'PROCESSING',
lockedBy: 'active-worker',
@@ -101,9 +118,11 @@ integration('database command queue', () => {
requestId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-10',
payload: {
type: 'vacation',
requestId,
userId: 'user-10',
generalId: 10,
} as GamePrisma.InputJsonValue,
},
@@ -113,20 +132,16 @@ integration('database command queue', () => {
const stale = new DatabaseTurnDaemonCommandQueue(db);
for (const attempt of [1, 2, 3]) {
await expect(owner.drain()).resolves.toEqual([
{ type: 'vacation', requestId, generalId: 10 },
{ type: 'vacation', requestId, userId: 'user-10', generalId: 10 },
]);
await stale.publishCommandError(requestId, new Error('stale worker failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'PROCESSING',
attempts: attempt,
});
await owner.publishCommandError(requestId, new Error('injected command failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: attempt < 3 ? 'PENDING' : 'FAILED',
attempts: attempt,
error: 'injected command failure',
@@ -137,4 +152,81 @@ integration('database command queue', () => {
await expect(owner.drain()).resolves.toEqual([]);
});
it('fails an actor-bound payload that omits userId instead of dispatching it', async () => {
const requestId = 'integration:engine:missing-user-id';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue,
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([]);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'FAILED',
error: 'Invalid command payload for vacation',
});
});
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
const requestId = 'integration:engine:stale-owner-replay';
const command: TurnDaemonCommand = {
type: 'vacation',
requestId,
userId: 'old-owner',
generalId: 7,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue,
},
});
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([command]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId,
eventType: command.type,
payload: command,
result,
lockedBy: null,
leaseUntil: null,
});
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
});
});