fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user