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
@@ -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({