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
@@ -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);
});
});