fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
@@ -92,6 +92,7 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
||||
id: 29,
|
||||
mailbox: actor.id,
|
||||
type: 'private',
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
message: {
|
||||
src: source,
|
||||
@@ -172,6 +173,76 @@ describe('actionable message response', () => {
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
|
||||
for (const row of [
|
||||
buildRow('scout', { option: { action: 'scout', used: 1 } }),
|
||||
{
|
||||
...buildRow('scout'),
|
||||
validUntil: new Date('0199-12-31T23:59:59.000Z'),
|
||||
},
|
||||
]) {
|
||||
const world = buildWorld();
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' });
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps PHP's special string-zero used value false", async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
|
||||
const { db, updateMany } = buildDb([[row], []]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, action: 'scout', reason: 'success' });
|
||||
expect(executor.execute).toHaveBeenCalledOnce();
|
||||
expect(updateMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects malformed actionable payloads without throwing inside the daemon transaction', async () => {
|
||||
const world = buildWorld();
|
||||
const row = { ...buildRow('scout'), message: { option: { action: 'scout' }, dest: null } };
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: '응답할 수 없는 메시지입니다.' });
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invalidate an invader prompt before validating its receiver', async () => {
|
||||
const world = buildWorld();
|
||||
const row = { ...buildRow('raiseInvader'), mailbox: 99 };
|
||||
|
||||
@@ -110,6 +110,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
const result = await auctionBidder.bid(
|
||||
{
|
||||
type: 'auctionBid',
|
||||
userId: 'user-7',
|
||||
auctionId: 31,
|
||||
generalId: general.id,
|
||||
amount,
|
||||
@@ -155,6 +156,7 @@ describe('resource auction Ref compatibility', () => {
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'auctionBid',
|
||||
userId: 'user-7',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
|
||||
@@ -120,6 +120,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const result = await openAuction(
|
||||
{
|
||||
type: 'auctionOpen',
|
||||
userId: 'user-7',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: general.id,
|
||||
amount: 6_000,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
|
||||
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||
{ type: 'troopKick', requestId: 'troopKick', userId, generalId: 7, troopId: 7, targetGeneralId: 8 },
|
||||
{ type: 'troopRename', requestId: 'troopRename', userId, generalId: 7, troopId: 7, troopName: '신대' },
|
||||
{ type: 'vacation', requestId: 'vacation', userId, generalId: 7 },
|
||||
{ type: 'setMySetting', requestId: 'setMySetting', userId, generalId: 7, settings: { tnmt: 1 } },
|
||||
{ type: 'dropItem', requestId: 'dropItem', userId, generalId: 7, itemType: 'weapon' },
|
||||
{
|
||||
type: 'auctionOpen',
|
||||
requestId: 'auctionOpen',
|
||||
userId,
|
||||
generalId: 7,
|
||||
auctionType: 'BUY_RICE',
|
||||
amount: 1_000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 100,
|
||||
finishBidAmount: 500,
|
||||
},
|
||||
{ type: 'auctionBid', requestId: 'auctionBid', userId, generalId: 7, auctionId: 1, amount: 200 },
|
||||
{
|
||||
type: 'changePermission',
|
||||
requestId: 'changePermission',
|
||||
userId,
|
||||
generalId: 7,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [],
|
||||
},
|
||||
{ type: 'kick', requestId: 'kick', userId, generalId: 7, destGeneralId: 8 },
|
||||
{
|
||||
type: 'appoint',
|
||||
requestId: 'appoint',
|
||||
userId,
|
||||
generalId: 7,
|
||||
destGeneralId: 8,
|
||||
destCityId: 1,
|
||||
officerLevel: 4,
|
||||
},
|
||||
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
|
||||
];
|
||||
|
||||
const buildReadOnlyWorld = (ownerUserId: string) => {
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: 7, userId: ownerUserId })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
return { world, mutation };
|
||||
};
|
||||
|
||||
describe('authenticated actor-bound command registry and execution', () => {
|
||||
it.each(['horse', 'weapon', 'book', 'item'] as const)(
|
||||
'accepts the Ref equipment slot %s for dropItem',
|
||||
(itemType) => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: `drop-item:${itemType}`,
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: { type: 'dropItem', userId: 'user-7', generalId: 7, itemType },
|
||||
})
|
||||
).toEqual({
|
||||
type: 'dropItem',
|
||||
requestId: `drop-item:${itemType}`,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
itemType,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['armor', '', 0, null])('rejects the invalid dropItem slot %j at the daemon boundary', (itemType) => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'drop-item:invalid-slot',
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'dropItem',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
itemType,
|
||||
} as unknown as TurnDaemonCommand,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects every actor-bound queue payload that omits userId', () => {
|
||||
for (const command of buildActorBoundCommands()) {
|
||||
const {
|
||||
userId: _userId,
|
||||
requestId: _requestId,
|
||||
...payload
|
||||
} = command as unknown as Record<string, unknown>;
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: `missing-user:${command.type}`,
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: payload as TurnDaemonCommand,
|
||||
}),
|
||||
command.type
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects all stale-owner commands before any world mutation', async () => {
|
||||
const commands = buildActorBoundCommands();
|
||||
const { world, mutation } = buildReadOnlyWorld('new-owner');
|
||||
const eventTypes = new Map(commands.map((command) => [command.requestId, command.type]));
|
||||
const db = {
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({
|
||||
actorUserId: 'old-owner',
|
||||
target: 'ENGINE',
|
||||
eventType: eventTypes.get(where.requestId),
|
||||
})),
|
||||
},
|
||||
};
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
for (const command of commands) {
|
||||
await expect(handler.handle(command, { db: db as never }), command.type).resolves.toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
}
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing event', null],
|
||||
['actor mismatch', { actorUserId: 'other-owner', target: 'ENGINE', eventType: 'vacation' }],
|
||||
['target mismatch', { actorUserId: 'old-owner', target: 'API', eventType: 'vacation' }],
|
||||
['event type mismatch', { actorUserId: 'old-owner', target: 'ENGINE', eventType: 'dropItem' }],
|
||||
])('returns commandRejected for %s without looking up or mutating the general', async (_label, event) => {
|
||||
const { world, mutation } = buildReadOnlyWorld('old-owner');
|
||||
const getGeneralById = world.getGeneralById as ReturnType<typeof vi.fn>;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const result = await handler.handle(
|
||||
{ type: 'vacation', requestId: 'vacation', userId: 'old-owner', generalId: 7 },
|
||||
{
|
||||
db: {
|
||||
inputEvent: { findUnique: vi.fn(async () => event) },
|
||||
} as never,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ type: 'commandRejected', ok: false, commandType: 'vacation' });
|
||||
expect(getGeneralById).not.toHaveBeenCalled();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves direct in-memory invocation when no command database is supplied', async () => {
|
||||
const updateGeneral = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: 7, userId: 'current-owner', meta: { killturn: 12 } })),
|
||||
getState: vi.fn(() => ({ meta: { killturn: 24, autorun_user: {} } })),
|
||||
updateGeneral,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'vacation', userId: 'different-owner', generalId: 7 })).resolves.toEqual({
|
||||
type: 'vacation',
|
||||
ok: true,
|
||||
generalId: 7,
|
||||
});
|
||||
expect(updateGeneral).toHaveBeenCalledWith(7, { meta: { killturn: 72 } });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('input event atomicity', () => {
|
||||
queue.enqueue({
|
||||
type: 'auctionBid',
|
||||
requestId: 'event-1',
|
||||
userId: 'user-7',
|
||||
auctionId: 3,
|
||||
generalId: 7,
|
||||
amount: 1000,
|
||||
@@ -239,7 +240,7 @@ describe('input event atomicity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-uow', generalId: 7 });
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-uow', userId: 'user-7', generalId: 7 });
|
||||
const loop = lifecycle.start();
|
||||
await responded;
|
||||
|
||||
@@ -304,7 +305,7 @@ describe('input event atomicity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-2', generalId: 7 });
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-2', userId: 'user-7', generalId: 7 });
|
||||
const loop = lifecycle.start();
|
||||
await errorObserved;
|
||||
|
||||
@@ -374,7 +375,7 @@ describe('input event atomicity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-3', generalId: 7 });
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-3', userId: 'user-7', generalId: 7 });
|
||||
const loop = lifecycle.start();
|
||||
await errorObserved;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
turnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
items: { horse: 'che_명마_02_조랑', weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
@@ -103,7 +103,21 @@ const buildWorld = (
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
cities: [],
|
||||
nations: [],
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#111111',
|
||||
typeCode: 'che_중립',
|
||||
level: 1,
|
||||
capitalCityId: null,
|
||||
chiefGeneralId: 7,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
@@ -216,6 +230,7 @@ describe('my information world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: {
|
||||
tnmt: 9,
|
||||
@@ -250,6 +265,7 @@ describe('my information world commands', () => {
|
||||
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { tnmt: 0, defence_train: 999, use_treatment: 1 },
|
||||
});
|
||||
@@ -270,6 +286,7 @@ describe('my information world commands', () => {
|
||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { defence_train: 999 },
|
||||
});
|
||||
@@ -279,26 +296,110 @@ describe('my information world commands', () => {
|
||||
|
||||
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
|
||||
const allowed = buildWorld();
|
||||
await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true });
|
||||
await expect(
|
||||
allowed.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 })
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72);
|
||||
|
||||
const blocked = buildWorld(buildGeneral(), { autorunLimit: true });
|
||||
await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
blocked.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 })
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
|
||||
});
|
||||
expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12);
|
||||
});
|
||||
|
||||
it('drops only the authenticated command target slot and rejects an empty slot', async () => {
|
||||
it('drops a buyable item with only the Ref personal action log', async () => {
|
||||
const fixture = buildWorld();
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' })
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' })
|
||||
).resolves.toMatchObject({ ok: false });
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' })
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'horse' })
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull();
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<C>조랑(+2)</>을 버렸습니다.',
|
||||
generalId: 7,
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds Ref global loss logs when dropping a non-buyable item', async () => {
|
||||
const fixture = buildWorld(
|
||||
buildGeneral({
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: 'che_서적_14_한비자', item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'book' })
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.getGeneralById(7)?.role.items.book).toBeNull();
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<C>한비자(+14)</>를 버렸습니다.',
|
||||
generalId: 7,
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<Y>테스트장수</>가 <C>한비자(+14)</>를 잃었습니다!',
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
text: '<R><b>【망실】</b></><D><b>테스트국</b></>의 <Y>테스트장수</>가 <C>한비자(+14)</>를 잃었습니다!',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops and logs an item stored under a mismatched equipment slot like Ref', async () => {
|
||||
const fixture = buildWorld(
|
||||
buildGeneral({
|
||||
role: {
|
||||
items: { horse: null, weapon: 'che_명마_02_조랑', book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' })
|
||||
).resolves.toMatchObject({ type: 'dropItem', ok: true, generalId: 7 });
|
||||
expect(fixture.world.getGeneralById(7)?.role.items.weapon).toBeNull();
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<C>조랑(+2)</>을 버렸습니다.',
|
||||
generalId: 7,
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('executes pre-open uprising through the action stack without advancing the turn clock', async () => {
|
||||
@@ -499,6 +600,7 @@ describe('my information world commands', () => {
|
||||
});
|
||||
|
||||
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
|
||||
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
||||
const recipient = buildGeneral({
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
@@ -506,6 +608,7 @@ describe('my information world commands', () => {
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const recruiter = buildGeneral({
|
||||
id: 9,
|
||||
@@ -566,6 +669,7 @@ describe('my information world commands', () => {
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||
experience: recruiter.experience + 100,
|
||||
@@ -592,6 +696,75 @@ describe('my information world commands', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts a recruitment letter after the recruiter was deleted and preserves the reserved command', async () => {
|
||||
const deletedRecruiterId = 99;
|
||||
const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } };
|
||||
const recipient = buildGeneral({
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
name: '재야장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const map = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||
};
|
||||
const fixture = buildImmediateActionWorld({
|
||||
general: recipient,
|
||||
cities: [
|
||||
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
||||
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
||||
] as TurnWorldSnapshot['cities'],
|
||||
nations: [
|
||||
{
|
||||
id: 2,
|
||||
name: '등용국',
|
||||
color: '#222222',
|
||||
typeCode: 'che_중립',
|
||||
level: 1,
|
||||
capitalCityId: 2,
|
||||
chiefGeneralId: deletedRecruiterId,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
meta: { gennum: 1 },
|
||||
},
|
||||
] as TurnWorldSnapshot['nations'],
|
||||
map,
|
||||
});
|
||||
const executor = await createImmediateGeneralActionExecutor({
|
||||
world: fixture.world,
|
||||
reservedTurns: fixture.reservedTurns,
|
||||
scenarioMeta: fixture.scenarioMeta,
|
||||
map,
|
||||
commandProfile: { general: ['che_등용'], nation: [] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: recipient.id,
|
||||
rng: new RandUtil(new LiteHashDRBG('accept-deleted-recruiter-letter')),
|
||||
args: { destNationId: 2, destGeneralId: deletedRecruiterId },
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getNationById(2)?.meta.gennum).toBe(2);
|
||||
expect(fixture.world.peekDirtyState().logs).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ generalId: deletedRecruiterId })])
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
|
||||
const general = buildGeneral({ nationId: 1, cityId: 1 });
|
||||
const fixture = buildImmediateActionWorld({
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic';
|
||||
import {
|
||||
loadActionModuleBundle,
|
||||
LogFormat,
|
||||
type GeneralActionModule,
|
||||
type TriggerValue,
|
||||
type TurnSchedule,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
@@ -25,7 +32,7 @@ const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGen
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 12, belong: 5, permission: 'normal' },
|
||||
meta: { killturn: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 },
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 1_000,
|
||||
@@ -48,6 +55,11 @@ const buildWorld = (options: {
|
||||
cityMeta?: Record<string, TriggerValue>;
|
||||
currentYear?: number;
|
||||
scenarioConst?: Record<string, unknown>;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule>;
|
||||
clock?: Pick<
|
||||
TurnWorldState,
|
||||
'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'
|
||||
>;
|
||||
}) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
@@ -56,6 +68,7 @@ const buildWorld = (options: {
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
meta: { killturn: 24, scenarioMeta: { startYear: 180 } },
|
||||
...options.clock,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: options.generals ?? [
|
||||
@@ -129,14 +142,24 @@ const buildWorld = (options: {
|
||||
},
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
return { world, handler: createTurnDaemonCommandHandler({ world }) };
|
||||
return {
|
||||
world,
|
||||
handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }),
|
||||
};
|
||||
};
|
||||
|
||||
describe('nation personnel world commands', () => {
|
||||
it('allows any unlocked head officer to appoint and preserves legacy officer state', async () => {
|
||||
const { world, handler } = buildWorld({});
|
||||
await expect(
|
||||
handler.handle({ type: 'appoint', generalId: 2, destGeneralId: 3, destCityId: 0, officerLevel: 9 })
|
||||
handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 0,
|
||||
officerLevel: 9,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(world.getGeneralById(3)).toMatchObject({
|
||||
officerLevel: 9,
|
||||
@@ -154,6 +177,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-3',
|
||||
generalId: 3,
|
||||
destGeneralId: 2,
|
||||
destCityId: 0,
|
||||
@@ -163,6 +187,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 0,
|
||||
@@ -175,6 +200,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
locked.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 0,
|
||||
@@ -196,6 +222,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 1,
|
||||
@@ -215,6 +242,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
locked.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 1,
|
||||
@@ -237,6 +265,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [3],
|
||||
@@ -245,6 +274,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [3, 5, 6],
|
||||
@@ -254,6 +284,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [2, 3, 4],
|
||||
@@ -263,6 +294,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [2, 3],
|
||||
@@ -271,6 +303,22 @@ describe('nation personnel world commands', () => {
|
||||
expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('ambassador');
|
||||
expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('ambassador');
|
||||
expect(fixture.world.getGeneralById(4)?.meta.permission).toBe('normal');
|
||||
|
||||
const clearCommand = normalizeTurnDaemonCommand({
|
||||
requestId: 'clear-ambassadors',
|
||||
sentAt: '2026-01-01T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [],
|
||||
},
|
||||
});
|
||||
expect(clearCommand).toMatchObject({ type: 'changePermission', targetGeneralIds: [] });
|
||||
await expect(fixture.handler.handle(clearCommand!)).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('normal');
|
||||
expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('normal');
|
||||
});
|
||||
|
||||
it('kicks for an unlocked head officer with resource, troop, permission, and log side effects', async () => {
|
||||
@@ -280,7 +328,14 @@ describe('nation personnel world commands', () => {
|
||||
rice: 3_000,
|
||||
experience: 1_000,
|
||||
dedication: 2_000,
|
||||
meta: { killturn: 12, permission: 'normal', belong: 8, betray: 1 },
|
||||
meta: {
|
||||
killturn: 12,
|
||||
permission: 'normal',
|
||||
belong: 8,
|
||||
betray: 1,
|
||||
explevel: 10,
|
||||
dedlevel: 5,
|
||||
},
|
||||
});
|
||||
const member = buildGeneral(4, { troopId: 3 });
|
||||
const fixture = buildWorld({
|
||||
@@ -289,7 +344,9 @@ describe('nation personnel world commands', () => {
|
||||
});
|
||||
fixture.world.createTroop({ id: 3, nationId: 1, name: '추방대' });
|
||||
|
||||
await expect(fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(3)).toMatchObject({
|
||||
@@ -309,7 +366,59 @@ describe('nation personnel world commands', () => {
|
||||
rice: 22_000,
|
||||
meta: expect.objectContaining({ gennum: 3 }),
|
||||
});
|
||||
expect(fixture.world.peekDirtyState().logs).toHaveLength(2);
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
expect.objectContaining({ scope: 'SYSTEM', category: 'SUMMARY' }),
|
||||
expect.objectContaining({
|
||||
scope: 'GENERAL',
|
||||
category: 'ACTION',
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: 3,
|
||||
text: '<D><b>위</b></>에서 <R>추방</>당했습니다.',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: 'GENERAL',
|
||||
category: 'ACTION',
|
||||
text: expect.stringContaining('레벨다운'),
|
||||
}),
|
||||
expect.objectContaining({ scope: 'GENERAL', category: 'HISTORY' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies Ref-ordered personality and item modifiers before legacy INT rounding on kick', async () => {
|
||||
const modules = (await loadActionModuleBundle()).general;
|
||||
const target = buildGeneral(3, {
|
||||
experience: 1_001,
|
||||
dedication: 2_001,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: 'che_명성_구석' },
|
||||
personality: 'che_대의',
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
meta: {
|
||||
killturn: 12,
|
||||
permission: 'normal',
|
||||
belong: 8,
|
||||
betray: 1,
|
||||
explevel: 10,
|
||||
dedlevel: 5,
|
||||
},
|
||||
});
|
||||
const fixture = buildWorld({
|
||||
generals: [buildGeneral(1, { officerLevel: 12 }), buildGeneral(2, { officerLevel: 5 }), target],
|
||||
generalActionModules: modules,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(3)).toMatchObject({
|
||||
experience: 803,
|
||||
dedication: 1_701,
|
||||
meta: expect.objectContaining({ explevel: 8, dedlevel: 5, betray: 2 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => {
|
||||
@@ -336,7 +445,12 @@ describe('nation personnel world commands', () => {
|
||||
const originalTarget = fixture.world.getGeneralById(testCase.targetId);
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId })
|
||||
fixture.handler.handle({
|
||||
type: 'kick',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: testCase.targetId,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, reason: testCase.reason });
|
||||
expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget);
|
||||
expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12);
|
||||
@@ -354,7 +468,7 @@ describe('nation personnel world commands', () => {
|
||||
buildGeneral(3, { meta: { killturn: 12, belong: 8, permission: 'normal', betray: 1 } }),
|
||||
],
|
||||
});
|
||||
await early.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 });
|
||||
await early.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 });
|
||||
expect(early.world.getGeneralById(3)).toMatchObject({
|
||||
experience: 850,
|
||||
dedication: 1_700,
|
||||
@@ -372,12 +486,54 @@ describe('nation personnel world commands', () => {
|
||||
buildGeneral(3, { npcState: 2 }),
|
||||
],
|
||||
});
|
||||
await npc.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 });
|
||||
await npc.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 });
|
||||
expect(npc.world.peekDirtyState().messages).toHaveLength(1);
|
||||
expect(npc.world.peekDirtyState().messages[0]).toMatchObject({
|
||||
msgType: 'public',
|
||||
src: { generalId: 3, nationId: 1 },
|
||||
dest: { generalId: 3, nationId: 1 },
|
||||
src: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' },
|
||||
dest: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' },
|
||||
});
|
||||
});
|
||||
|
||||
it('timestamps a queued NPC kick message from the durable accepted instant', async () => {
|
||||
const acceptedAt = new Date('2026-01-01T00:10:00.000Z');
|
||||
const fixture = buildWorld({
|
||||
currentYear: 185,
|
||||
scenarioConst: { npcBanMessageProb: 1 },
|
||||
clock: {
|
||||
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
},
|
||||
generals: [
|
||||
buildGeneral(1, { officerLevel: 12 }),
|
||||
buildGeneral(2, { officerLevel: 5 }),
|
||||
buildGeneral(3, { npcState: 2 }),
|
||||
],
|
||||
});
|
||||
const command = {
|
||||
type: 'kick' as const,
|
||||
requestId: 'kick-accepted-time',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
};
|
||||
const db = {
|
||||
inputEvent: {
|
||||
findUnique: async () => ({
|
||||
createdAt: acceptedAt,
|
||||
actorUserId: command.userId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(fixture.handler.handle(command, { db: db as never })).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.peekDirtyState().messages[0]?.time).toEqual(
|
||||
new Date('0185-01-01T00:10:00.000Z')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { applyNationSettingMutation } from '../src/turn/nationSettingMutation.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
type SetNationSettingCommand = Extract<TurnDaemonCommand, { type: 'setNationSetting' }>;
|
||||
type NationSettingMutation = SetNationSettingCommand['mutation'];
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const acceptedAt = new Date('2026-02-03T04:05:06.000Z');
|
||||
|
||||
const general: TurnGeneral = {
|
||||
id: 1,
|
||||
userId: 'owner-1',
|
||||
name: '테스트군주',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 75, strength: 40, intelligence: 70 },
|
||||
turnTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
penalty: {},
|
||||
officerLevel: 12,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1100,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
};
|
||||
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '허창',
|
||||
nationId: 1,
|
||||
level: 7,
|
||||
state: 0,
|
||||
population: 100_000,
|
||||
populationMax: 200_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#777777',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 20_000,
|
||||
power: 0,
|
||||
level: 3,
|
||||
typeCode: 'che_법가',
|
||||
meta: { tech: 3_000, preserved: 'yes' },
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'basic' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'test',
|
||||
startYear: 180,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
meta: { killturn: 24 },
|
||||
};
|
||||
|
||||
const createWorld = (options?: {
|
||||
general?: Partial<TurnGeneral>;
|
||||
nationMeta?: TurnWorldSnapshot['nations'][number]['meta'];
|
||||
worldMeta?: Record<string, unknown>;
|
||||
}): InMemoryTurnWorld => {
|
||||
const nextSnapshot = structuredClone(snapshot);
|
||||
nextSnapshot.generals[0] = { ...nextSnapshot.generals[0]!, ...options?.general };
|
||||
nextSnapshot.nations[0]!.meta = {
|
||||
...nextSnapshot.nations[0]!.meta,
|
||||
...options?.nationMeta,
|
||||
};
|
||||
return new InMemoryTurnWorld(
|
||||
{
|
||||
...state,
|
||||
meta: { ...state.meta, ...options?.worldMeta },
|
||||
},
|
||||
nextSnapshot,
|
||||
{ schedule }
|
||||
);
|
||||
};
|
||||
|
||||
const command = (
|
||||
mutation: NationSettingMutation,
|
||||
overrides?: Partial<Omit<SetNationSettingCommand, 'type' | 'mutation'>>
|
||||
): SetNationSettingCommand => ({
|
||||
type: 'setNationSetting',
|
||||
requestId: 'nation-setting-test',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
mutation,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('nation setting mutation', () => {
|
||||
it('keeps raw required validation at the API boundary and only enforces code-point length durably', () => {
|
||||
const normalizeNotice = (message: string) =>
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'nation-setting-text-boundary',
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind: 'notice', message }),
|
||||
});
|
||||
|
||||
expect(normalizeNotice('')).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
mutation: { kind: 'notice', message: '' },
|
||||
});
|
||||
expect(normalizeNotice(' \t\n\v\0')).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
mutation: { kind: 'notice', message: ' \t\n\v\0' },
|
||||
});
|
||||
expect(normalizeNotice(' ')).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
mutation: { kind: 'notice', message: ' ' },
|
||||
});
|
||||
expect(normalizeNotice('😀'.repeat(16_384))).not.toBeNull();
|
||||
expect(normalizeNotice('😀'.repeat(16_385))).toBeNull();
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'nation-setting-scout-text-boundary',
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_000) }),
|
||||
})
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'nation-setting-scout-text-overflow',
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_001) }),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['notice', 'notice', 'nationNotice'],
|
||||
['scoutMessage', 'infoText', null],
|
||||
] as const)('stores an empty sanitized %s string like Ref', (kind, metaKey, structuredMetaKey) => {
|
||||
const normalized = normalizeTurnDaemonCommand({
|
||||
requestId: `nation-setting-empty-${kind}`,
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind, message: '' }),
|
||||
});
|
||||
expect(normalized).not.toBeNull();
|
||||
if (!normalized || normalized.type !== 'setNationSetting') {
|
||||
throw new Error('setNationSetting normalization failed');
|
||||
}
|
||||
|
||||
const world = createWorld();
|
||||
expect(applyNationSettingMutation({ world, command: normalized, acceptedAt })).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta[metaKey]).toBe('');
|
||||
if (structuredMetaKey) {
|
||||
expect(world.getNationById(1)?.meta[structuredMetaKey]).toMatchObject({ msg: '' });
|
||||
}
|
||||
});
|
||||
|
||||
it('rechecks owner, nation, and permission at execution time without mutating nation metadata on rejection', () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
world: InMemoryTurnWorld;
|
||||
command: SetNationSettingCommand;
|
||||
code: 'FORBIDDEN' | 'PRECONDITION_FAILED';
|
||||
}> = [
|
||||
{
|
||||
name: 'owner changed',
|
||||
world: createWorld(),
|
||||
command: command({ kind: 'rate', amount: 20 }, { userId: 'other-owner' }),
|
||||
code: 'FORBIDDEN',
|
||||
},
|
||||
{
|
||||
name: 'nation changed',
|
||||
world: createWorld({ general: { nationId: 2 } }),
|
||||
command: command({ kind: 'rate', amount: 20 }),
|
||||
code: 'PRECONDITION_FAILED',
|
||||
},
|
||||
{
|
||||
name: 'permission revoked',
|
||||
world: createWorld({ general: { officerLevel: 2 } }),
|
||||
command: command({ kind: 'rate', amount: 20 }),
|
||||
code: 'FORBIDDEN',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const before = structuredClone(testCase.world.getNationById(1)?.meta);
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world: testCase.world,
|
||||
command: testCase.command,
|
||||
acceptedAt,
|
||||
}),
|
||||
testCase.name
|
||||
).toMatchObject({ type: 'setNationSetting', ok: false, code: testCase.code });
|
||||
expect(testCase.world.getNationById(1)?.meta, testCase.name).toEqual(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the special editable-permission rules for high officers and low ambassadors', () => {
|
||||
const highOfficer = createWorld({
|
||||
general: { officerLevel: 5, penalty: { noChief: true } },
|
||||
});
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world: highOfficer,
|
||||
command: command({ kind: 'rate', amount: 20 }, { requestId: 'high-officer' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true });
|
||||
expect(highOfficer.getNationById(1)?.meta).toMatchObject({ rate: 20, preserved: 'yes' });
|
||||
|
||||
const lowAmbassador = createWorld({
|
||||
general: { officerLevel: 2, meta: { killturn: 24, permission: 'ambassador' } },
|
||||
});
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world: lowAmbassador,
|
||||
command: command({ kind: 'bill', amount: 100 }, { requestId: 'low-ambassador' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true });
|
||||
expect(lowAmbassador.getNationById(1)?.meta).toMatchObject({ bill: 100, preserved: 'yes' });
|
||||
});
|
||||
|
||||
it('stores the notice text and author snapshot at logical game time', () => {
|
||||
const world = createWorld();
|
||||
const result = applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'notice', message: '새 국가 방침' }, { requestId: 'notice-logical-time' }),
|
||||
acceptedAt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({
|
||||
preserved: 'yes',
|
||||
notice: '새 국가 방침',
|
||||
nationNotice: {
|
||||
date: '0185-01-01 09:00:00',
|
||||
msg: '새 국가 방침',
|
||||
author: '테스트군주',
|
||||
authorID: 1,
|
||||
},
|
||||
_updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an absent war-setting counter as zero and leaves metadata unchanged', () => {
|
||||
const world = createWorld();
|
||||
const before = structuredClone(world.getNationById(1)?.meta);
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: true }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toEqual({
|
||||
type: 'setNationSetting',
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '잔여 횟수가 부족합니다.',
|
||||
nationId: 1,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toEqual(before);
|
||||
});
|
||||
|
||||
it('consumes the current war-setting counter sequentially and rejects after exhaustion', () => {
|
||||
const world = createWorld({ nationMeta: { available_war_setting_cnt: 2 } });
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-1' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 1 });
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({ war: 1, available_war_setting_cnt: 1 });
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: false }, { requestId: 'block-war-2' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 0 });
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({ war: 0, available_war_setting_cnt: 0 });
|
||||
|
||||
const beforeRejected = structuredClone(world.getNationById(1)?.meta);
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-3' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: false, code: 'BAD_REQUEST' });
|
||||
expect(world.getNationById(1)?.meta).toEqual(beforeRejected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['null', null],
|
||||
['false', false],
|
||||
['zero', 0],
|
||||
['empty string', ''],
|
||||
['string zero', '0'],
|
||||
['empty array', []],
|
||||
])('allows scout changes when the legacy lock value is falsey: %s', (_name, lockValue) => {
|
||||
const world = createWorld({
|
||||
worldMeta: lockValue === undefined ? {} : { block_change_scout: lockValue },
|
||||
});
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockScout', value: true }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true });
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({ scout: 1, preserved: 'yes' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['true', true],
|
||||
['one', 1],
|
||||
['string one', '1'],
|
||||
['non-empty array', [0]],
|
||||
['object', {}],
|
||||
])('rejects scout changes without mutation when the legacy lock value is truthy: %s', (_name, lockValue) => {
|
||||
const world = createWorld({ worldMeta: { block_change_scout: lockValue } });
|
||||
const before = structuredClone(world.getNationById(1)?.meta);
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockScout', value: true }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toEqual({
|
||||
type: 'setNationSetting',
|
||||
ok: false,
|
||||
code: 'FORBIDDEN',
|
||||
reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
||||
nationId: 1,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { AutorunNationPolicy } from '../src/turn/ai/policies.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const general: TurnGeneral = {
|
||||
@@ -83,7 +83,7 @@ const snapshot: TurnWorldSnapshot = {
|
||||
meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
troops: [{ id: 101, nationId: 1, name: '선봉부대' }],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
@@ -176,28 +176,71 @@ const unitSet: UnitSetDefinition = {
|
||||
};
|
||||
|
||||
describe('NPC policy lifecycle', () => {
|
||||
it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => {
|
||||
it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => {
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const updates = {
|
||||
const first = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
requestId: 'npc-policy-values',
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPolicy', values: { reqNationGold: 4_321 } },
|
||||
},
|
||||
});
|
||||
expect(first).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
if (!first.ok) {
|
||||
throw new Error(first.reason);
|
||||
}
|
||||
|
||||
world.updateNation(1, {
|
||||
meta: {
|
||||
...world.getNationById(1)!.meta,
|
||||
_updatedAt: '2026-02-03T04:05:06.500Z#unrelated-setting',
|
||||
},
|
||||
});
|
||||
|
||||
const second = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:07.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
requestId: 'npc-policy-priority',
|
||||
expectedUpdatedAt: first.updatedAt,
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
if (!second.ok) {
|
||||
throw new Error(second.reason);
|
||||
}
|
||||
|
||||
const nation = world.getNationById(1)!;
|
||||
expect(nation.meta).toMatchObject({
|
||||
preserved: 'yes',
|
||||
npc_nation_policy: {
|
||||
values: { reqNationGold: 4_321 },
|
||||
priority: ['천도'],
|
||||
valueSetter: '정책담당',
|
||||
valueSetter: 'NPC군주',
|
||||
prioritySetter: 'NPC군주',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 });
|
||||
|
||||
const nation = world.getNationById(1)!;
|
||||
expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy });
|
||||
});
|
||||
const policy = new AutorunNationPolicy({
|
||||
general: world.getGeneralById(1)!,
|
||||
aiOptions: null,
|
||||
@@ -214,17 +257,214 @@ describe('NPC policy lifecycle', () => {
|
||||
expect(policy.reqNpcWarGold).toBe(3_900);
|
||||
expect(policy.reqNpcWarRice).toBe(3_900);
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } },
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
const beforeConflict = structuredClone(world.getNationById(1)?.meta);
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:08.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPolicy', values: { reqNationGold: 9_999 } },
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' });
|
||||
).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: false,
|
||||
code: 'CONFLICT',
|
||||
currentUpdatedAt: second.updatedAt,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toEqual(beforeConflict);
|
||||
expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({
|
||||
reqNationGold: 4_321,
|
||||
});
|
||||
expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 });
|
||||
});
|
||||
|
||||
it('requires an exact nullable revision when policy metadata has never been versioned', () => {
|
||||
const noRevisionSnapshot = structuredClone(snapshot);
|
||||
delete noRevisionSnapshot.nations[0]!.meta._npcPolicyUpdatedAt;
|
||||
delete noRevisionSnapshot.nations[0]!.meta._updatedAt;
|
||||
const world = new InMemoryTurnWorld(state, noRevisionSnapshot, { schedule });
|
||||
const initialMeta = structuredClone(world.getNationById(1)?.meta);
|
||||
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ type: 'setNpcPolicy', ok: false, code: 'CONFLICT', currentUpdatedAt: null });
|
||||
expect(world.getNationById(1)?.meta).toEqual(initialMeta);
|
||||
|
||||
const accepted = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:07.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
requestId: 'initial-null-revision',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: null,
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
});
|
||||
expect(accepted).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
if (!accepted.ok) {
|
||||
throw new Error(accepted.reason);
|
||||
}
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({
|
||||
preserved: 'yes',
|
||||
_npcPolicyUpdatedAt: accepted.updatedAt,
|
||||
npc_nation_policy: { priority: ['천도'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates and merges policy intent against current ENGINE state without materialising defaults', () => {
|
||||
const world = new InMemoryTurnWorld(
|
||||
{
|
||||
...state,
|
||||
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
},
|
||||
snapshot,
|
||||
{ schedule }
|
||||
);
|
||||
world.updateNation(1, {
|
||||
meta: {
|
||||
...world.getNationById(1)!.meta,
|
||||
npc_nation_policy: { values: { reqNationRice: 456 }, preserved: 'root' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: {
|
||||
kind: 'nationPolicy',
|
||||
values: {
|
||||
reqNationGold: -100,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: {},
|
||||
SupportForce: [101],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
expect(asRecord(world.getNationById(1)?.meta).npc_nation_policy).toEqual({
|
||||
values: {
|
||||
reqNationRice: 456,
|
||||
reqNationGold: 0,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: {},
|
||||
SupportForce: [101],
|
||||
},
|
||||
preserved: 'root',
|
||||
valueSetter: 'NPC군주',
|
||||
valueSetTime: '0185-01-01 09:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale authority, empty input, malformed combat targets, and lost CAS inside ENGINE', () => {
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const baseCommand = {
|
||||
type: 'setNpcPolicy' as const,
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
const acceptedAt = new Date('2026-02-03T04:05:06.000Z');
|
||||
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: { ...baseCommand, mutation: { kind: 'nationPolicy', values: {} } },
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'BAD_REQUEST' });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: {
|
||||
...baseCommand,
|
||||
mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1, 1] } } },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'BAD_REQUEST', reason: '101의 입력양식이 올바르지 않습니다.' });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: {
|
||||
...baseCommand,
|
||||
mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1] } } },
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '101의 도시 , 가 올바른 도시 번호가 아닙니다.',
|
||||
});
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: [] } },
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'BAD_REQUEST' });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: {
|
||||
...baseCommand,
|
||||
expectedUpdatedAt: '1999-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'CONFLICT' });
|
||||
|
||||
world.updateGeneral(1, { officerLevel: 2 });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: ['천도'] } },
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'FORBIDDEN' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,67 @@ describe('old nation archive data', () => {
|
||||
aux: { legacy: 'preserved', maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
|
||||
generals: [7, 8],
|
||||
history: ['위가 멸망'],
|
||||
msg: '',
|
||||
scout_msg: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers current notice fields and preserves empty strings', () => {
|
||||
const nation: Nation = {
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#0000ff',
|
||||
capitalCityId: 3,
|
||||
chiefGeneralId: 7,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
power: 8_000,
|
||||
level: 5,
|
||||
typeCode: 'che_법가',
|
||||
meta: {
|
||||
notice: '',
|
||||
infoText: '',
|
||||
nationNotice: { msg: 'legacy notice' },
|
||||
msg: 'legacy flat notice',
|
||||
scout_msg: 'legacy scout message',
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildOldNationArchiveData({ nation, generalIds: [], history: [] })).toMatchObject({
|
||||
msg: '',
|
||||
scout_msg: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to both legacy notice shapes and legacy scout text', () => {
|
||||
const baseNation: Nation = {
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#0000ff',
|
||||
capitalCityId: 3,
|
||||
chiefGeneralId: 7,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
power: 8_000,
|
||||
level: 5,
|
||||
typeCode: 'che_법가',
|
||||
meta: {
|
||||
nationNotice: { msg: 'legacy notice' },
|
||||
msg: 'legacy flat notice',
|
||||
scout_msg: 'legacy scout message',
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildOldNationArchiveData({ nation: baseNation, generalIds: [], history: [] })).toMatchObject({
|
||||
msg: 'legacy notice',
|
||||
scout_msg: 'legacy scout message',
|
||||
});
|
||||
expect(
|
||||
buildOldNationArchiveData({
|
||||
nation: { ...baseNation, meta: { msg: 'legacy flat notice' } },
|
||||
generalIds: [],
|
||||
history: [],
|
||||
})
|
||||
).toMatchObject({ msg: 'legacy flat notice', scout_msg: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }]
|
||||
|
||||
const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id,
|
||||
userId: `user-${id}`,
|
||||
name: `장수${id}`,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
@@ -132,7 +133,9 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toEqual({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
|
||||
).resolves.toEqual({
|
||||
type: 'troopJoin',
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
@@ -159,7 +162,9 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 1 });
|
||||
@@ -177,7 +182,9 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 2 });
|
||||
@@ -188,7 +195,9 @@ describe('troop management world commands', () => {
|
||||
const world = buildWorld({});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopCreate', generalId: 1, troopName: ' 백마대 ' })).resolves.toEqual({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' 백마대 ' })
|
||||
).resolves.toEqual({
|
||||
type: 'troopCreate',
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
@@ -202,7 +211,12 @@ describe('troop management world commands', () => {
|
||||
const escapedWorld = buildWorld({ generals: [buildGeneral(2)] });
|
||||
const escapedHandler = createTurnDaemonCommandHandler({ world: escapedWorld });
|
||||
await expect(
|
||||
escapedHandler.handle({ type: 'troopCreate', generalId: 2, troopName: '<백마대>' })
|
||||
escapedHandler.handle({
|
||||
type: 'troopCreate',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopName: '<백마대>',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, troopName: '<백마대>' });
|
||||
});
|
||||
|
||||
@@ -213,13 +227,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const assignedHandler = createTurnDaemonCommandHandler({ world: assigned });
|
||||
await expect(
|
||||
assignedHandler.handle({ type: 'troopCreate', generalId: 1, troopName: '신규대' })
|
||||
assignedHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: '신규대' })
|
||||
).resolves.toMatchObject({ ok: false, reason: '이미 부대에 소속되어 있습니다.' });
|
||||
|
||||
const blank = buildWorld({});
|
||||
const blankHandler = createTurnDaemonCommandHandler({ world: blank });
|
||||
await expect(
|
||||
blankHandler.handle({ type: 'troopCreate', generalId: 1, troopName: ' ' })
|
||||
blankHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' ' })
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: '부대 이름이 없습니다.',
|
||||
@@ -244,6 +258,7 @@ describe('troop management world commands', () => {
|
||||
await expect(
|
||||
forbiddenHandler.handle({
|
||||
type: 'troopKick',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopId: 1,
|
||||
targetGeneralId: 3,
|
||||
@@ -256,6 +271,7 @@ describe('troop management world commands', () => {
|
||||
await expect(
|
||||
allowedHandler.handle({
|
||||
type: 'troopKick',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopId: 1,
|
||||
targetGeneralId: 3,
|
||||
@@ -266,6 +282,7 @@ describe('troop management world commands', () => {
|
||||
await expect(
|
||||
allowedHandler.handle({
|
||||
type: 'troopKick',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopId: 1,
|
||||
targetGeneralId: 1,
|
||||
@@ -280,7 +297,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const leaderHandler = createTurnDaemonCommandHandler({ world: leaderWorld });
|
||||
await expect(
|
||||
leaderHandler.handle({ type: 'troopRename', generalId: 1, troopId: 1, troopName: '신대' })
|
||||
leaderHandler.handle({
|
||||
type: 'troopRename',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopId: 1,
|
||||
troopName: '신대',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, troopName: '신대' });
|
||||
|
||||
const managerWorld = buildWorld({
|
||||
@@ -292,7 +315,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const managerHandler = createTurnDaemonCommandHandler({ world: managerWorld });
|
||||
await expect(
|
||||
managerHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
|
||||
managerHandler.handle({
|
||||
type: 'troopRename',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopId: 1,
|
||||
troopName: '신대',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, troopName: '신대' });
|
||||
|
||||
const penalizedWorld = buildWorld({
|
||||
@@ -307,7 +336,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const penalizedHandler = createTurnDaemonCommandHandler({ world: penalizedWorld });
|
||||
await expect(
|
||||
penalizedHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
|
||||
penalizedHandler.handle({
|
||||
type: 'troopRename',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopId: 1,
|
||||
troopName: '신대',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' });
|
||||
expect(penalizedWorld.getTroopById(1)?.name).toBe('구대');
|
||||
});
|
||||
|
||||
@@ -80,6 +80,8 @@ integration('unification finalization transaction', () => {
|
||||
meta: {
|
||||
power: 3_000,
|
||||
max_power: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
|
||||
notice: '통일 공지',
|
||||
infoText: '통일 임관 안내',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -268,6 +270,7 @@ integration('unification finalization transaction', () => {
|
||||
await expect(
|
||||
bidder.bid({
|
||||
type: 'auctionBid',
|
||||
userId,
|
||||
auctionId: uniqueAuction.id,
|
||||
generalId: fixtureId,
|
||||
amount: 30,
|
||||
@@ -277,6 +280,7 @@ integration('unification finalization transaction', () => {
|
||||
await expect(
|
||||
bidder.bid({
|
||||
type: 'auctionBid',
|
||||
userId,
|
||||
auctionId: uniqueAuction.id,
|
||||
generalId: fixtureId,
|
||||
amount: 50,
|
||||
@@ -404,6 +408,8 @@ integration('unification finalization transaction', () => {
|
||||
maxCities: ['원자도시'],
|
||||
aux: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
|
||||
generals: [fixtureId],
|
||||
msg: '통일 공지',
|
||||
scout_msg: '통일 임관 안내',
|
||||
});
|
||||
expect(legacyOfficerPicture.length).toBeGreaterThan(32);
|
||||
expect(await db.emperor.findFirstOrThrow({ where: { serverId } })).toMatchObject({
|
||||
|
||||
@@ -245,10 +245,16 @@ describe('unification handler', () => {
|
||||
auctionBidder: { bid },
|
||||
});
|
||||
await expect(
|
||||
commands.handle({ type: 'auctionBid', auctionId: 77, generalId: 1, amount: 100 })
|
||||
commands.handle({ type: 'auctionBid', userId: 'user-1', auctionId: 77, generalId: 1, amount: 100 })
|
||||
).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' });
|
||||
await expect(
|
||||
commands.handle({ type: 'auctionOpen', auctionType: 'UNIQUE_ITEM', generalId: 1, amount: 100 })
|
||||
commands.handle({
|
||||
type: 'auctionOpen',
|
||||
userId: 'user-1',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: 1,
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' });
|
||||
expect(bid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ const buildWorld = (): InMemoryTurnWorld => {
|
||||
power: 3000,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
meta: { notice: '통일 공지', infoText: '통일 임관 안내' },
|
||||
};
|
||||
const city: City = {
|
||||
id: 1,
|
||||
@@ -184,6 +184,7 @@ describe('persistUnificationFinalization', () => {
|
||||
const gameHistoryUpdate = vi.fn().mockResolvedValue({});
|
||||
const emperorCreate = vi.fn().mockResolvedValue({});
|
||||
const oldGeneralUpsert = vi.fn().mockResolvedValue({});
|
||||
const oldNationUpsert = vi.fn().mockResolvedValue({});
|
||||
const transaction = Object.assign({} as GamePrisma.TransactionClient, {
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
$queryRaw: vi.fn().mockResolvedValue([]),
|
||||
@@ -233,7 +234,7 @@ describe('persistUnificationFinalization', () => {
|
||||
),
|
||||
},
|
||||
oldNation: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
upsert: oldNationUpsert,
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
oldGeneral: { upsert: oldGeneralUpsert },
|
||||
@@ -282,6 +283,14 @@ describe('persistUnificationFinalization', () => {
|
||||
expect(gameHistoryUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
|
||||
);
|
||||
expect(oldNationUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
nation: 1,
|
||||
data: expect.objectContaining({ msg: '통일 공지', scout_msg: '통일 임관 안내' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(emperorCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../sr
|
||||
|
||||
const buildGeneral = (id: number): TurnGeneral => ({
|
||||
id,
|
||||
userId: `user-${id}`,
|
||||
name: `General_${id}`,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
@@ -46,6 +47,12 @@ const buildGeneral = (id: number): TurnGeneral => ({
|
||||
npcState: 0,
|
||||
});
|
||||
|
||||
const actorBindingDb = (userId = 'user-1') => ({
|
||||
inputEvent: {
|
||||
findUnique: async () => ({ actorUserId: userId, target: 'ENGINE', eventType: 'voteReward' }),
|
||||
},
|
||||
});
|
||||
|
||||
describe('voteReward command', () => {
|
||||
it('keeps the wall-time fallback open at exact deadline equality', () => {
|
||||
const deadline = new Date('0180-01-01T00:00:00.000Z');
|
||||
@@ -67,6 +74,7 @@ describe('voteReward command', () => {
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'voteReward',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
@@ -224,6 +232,7 @@ describe('voteReward command', () => {
|
||||
let voteQueryCount = 0;
|
||||
let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined;
|
||||
const commandDb = {
|
||||
...actorBindingDb(),
|
||||
auction: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
@@ -249,6 +258,8 @@ describe('voteReward command', () => {
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward-1',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
@@ -269,9 +280,7 @@ describe('voteReward command', () => {
|
||||
expect(
|
||||
voteInsertQuery?.values.filter(
|
||||
(value) =>
|
||||
value instanceof Date &&
|
||||
value.getTime() >= writerWindowStart &&
|
||||
value.getTime() <= writerWindowEnd
|
||||
value instanceof Date && value.getTime() >= writerWindowStart && value.getTime() <= writerWindowEnd
|
||||
)
|
||||
).toHaveLength(1);
|
||||
|
||||
@@ -311,6 +320,7 @@ describe('voteReward command', () => {
|
||||
const duplicateHandler = createTurnDaemonCommandHandler({ world: duplicateWorld });
|
||||
const duplicateResult = await duplicateHandler.handle(command, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
@@ -346,6 +356,7 @@ describe('voteReward command', () => {
|
||||
const mismatchHandler = createTurnDaemonCommandHandler({ world: mismatchWorld });
|
||||
const mismatchResult = await mismatchHandler.handle(command, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
@@ -383,6 +394,7 @@ describe('voteReward command', () => {
|
||||
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
|
||||
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
@@ -456,6 +468,7 @@ describe('voteReward command', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const commandDb = {
|
||||
...actorBindingDb(),
|
||||
auction: {
|
||||
findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }],
|
||||
},
|
||||
@@ -476,6 +489,8 @@ describe('voteReward command', () => {
|
||||
const result = await handler.handle(
|
||||
{
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-reward-occupied',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
|
||||
Reference in New Issue
Block a user