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