feat: 응답 가능한 서신과 턴 시간 호환 이관
등용장과 이민족 선택 응답을 turn daemon transaction으로 연결하고 Ref의 통일 이후 상태 전이와 수신자별 메시지 저장 규칙을 보존한다.\n\n유산 턴 시간 변경을 nextTurnTimeBase 기반 결정적 계산으로 바로잡고 API, 엔진, Chromium 회귀를 추가한다.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { MessagePayload } from '@sammo-ts/logic';
|
||||
|
||||
import { respondToActionableMessage } from '../src/turn/actionableMessageResponse.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { ImmediateGeneralActionExecutor } from '../src/turn/reservedTurnHandler.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const actor: TurnGeneral = {
|
||||
id: 7,
|
||||
userId: 'user-7',
|
||||
name: '수신자',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
turnTime: new Date('0200-01-01T00:10:00.000Z'),
|
||||
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: 1,
|
||||
experience: 100,
|
||||
dedication: 100,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
};
|
||||
|
||||
const buildWorld = (): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { hiddenSeed: 'actionable-message-test', isunited: 2 },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [actor],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
};
|
||||
|
||||
const source = {
|
||||
generalId: 8,
|
||||
generalName: '제안자',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
};
|
||||
const destination = {
|
||||
generalId: actor.id,
|
||||
generalName: actor.name,
|
||||
nationId: actor.nationId,
|
||||
nationName: '위',
|
||||
color: '#ffffff',
|
||||
icon: '',
|
||||
};
|
||||
|
||||
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
|
||||
id: 29,
|
||||
mailbox: actor.id,
|
||||
type: 'private',
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
message: {
|
||||
src: source,
|
||||
dest: destination,
|
||||
text: '응답할 메시지',
|
||||
option: { action, used: false, ...(action === 'raiseInvader' ? { args: [-2, -1.2, -1, -0.5] } : {}) },
|
||||
...overrides,
|
||||
} satisfies MessagePayload,
|
||||
});
|
||||
|
||||
const buildDb = (rows: unknown[][]) => {
|
||||
const queryRaw = vi.fn(async () => rows.shift() ?? []);
|
||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||
return {
|
||||
db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient,
|
||||
queryRaw,
|
||||
updateMany,
|
||||
};
|
||||
};
|
||||
|
||||
const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
|
||||
execute: vi.fn(async () => (ok ? { ok: true } : { ok: false, reason: '등용 수락 불가.' })),
|
||||
});
|
||||
|
||||
describe('actionable message response', () => {
|
||||
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout');
|
||||
const { db, updateMany } = buildDb([[row], [{ id: 31 }]]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'scout', reason: 'success' });
|
||||
expect(executor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: actor.id,
|
||||
args: { destNationId: source.nationId, destGeneralId: source.generalId },
|
||||
})
|
||||
);
|
||||
expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [row.id, 31] } } }));
|
||||
expect(world.peekDirtyState().messages).toEqual([
|
||||
expect.objectContaining({
|
||||
msgType: 'private',
|
||||
text: '촉으로 등용 제의 수락',
|
||||
sendDestOnly: true,
|
||||
option: { delete: row.id },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a recruitment letter valid when the legacy accept constraints reject it', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout');
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(false),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'scout', reason: '등용 수락 불가.' });
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not invalidate an invader prompt before validating its receiver', async () => {
|
||||
const world = buildWorld();
|
||||
const row = { ...buildRow('raiseInvader'), mailbox: 99 };
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, action: 'raiseInvader', reason: '올바른 수신자가 아닙니다.' });
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates a valid invader prompt when it is declined', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('raiseInvader');
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'raiseInvader', reason: 'success' });
|
||||
expect(updateMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const buildWorld = (isunited: number): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { isunited },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('invader event-game month progression', () => {
|
||||
it('continues monthly processing while the invader game is active', async () => {
|
||||
const world = buildWorld(1);
|
||||
const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), {
|
||||
budgetMs: 1_000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
|
||||
expect(result.processedTurns).toBe(1);
|
||||
expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 2 });
|
||||
});
|
||||
|
||||
it.each([2, 3])('stops monthly processing at terminal united state %s', async (isunited) => {
|
||||
const world = buildWorld(isunited);
|
||||
const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), {
|
||||
budgetMs: 1_000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
|
||||
expect(result.processedTurns).toBe(0);
|
||||
expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 1 });
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@ const nation: Nation = {
|
||||
gold: 1000,
|
||||
rice: 2000,
|
||||
power: 3000,
|
||||
level: 1,
|
||||
level: 4,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
};
|
||||
@@ -82,7 +82,7 @@ const city: City = {
|
||||
id: 1,
|
||||
name: '통일도시',
|
||||
nationId: 1,
|
||||
level: 1,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 1000,
|
||||
populationMax: 2000,
|
||||
@@ -202,6 +202,33 @@ describe('unification handler', () => {
|
||||
expect(world.peekDirtyState().pendingUnificationFinalizations).toEqual([
|
||||
expect.objectContaining({ auctionCancellations: [auctionCancellation, legacyAuctionCancellation] }),
|
||||
]);
|
||||
expect(
|
||||
world.peekDirtyState().messages.map((message) => ({
|
||||
text: message.text,
|
||||
action: message.option?.action,
|
||||
args: message.option?.args,
|
||||
recipient: message.dest.generalId,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
text: '이벤트 게임으로 이민족[어려움]을 소환',
|
||||
action: 'raiseInvader',
|
||||
args: [-2, -1.2, 15_000, -1],
|
||||
recipient: 1,
|
||||
},
|
||||
{
|
||||
text: '이벤트 게임으로 이민족[보통]을 소환',
|
||||
action: 'raiseInvader',
|
||||
args: [-2, -1.2, -1, -0.5],
|
||||
recipient: 1,
|
||||
},
|
||||
{
|
||||
text: '이벤트 게임으로 이민족[쉬움]을 소환',
|
||||
action: 'raiseInvader',
|
||||
args: [-1, -1, -0.8, 0],
|
||||
recipient: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const bid = vi.fn();
|
||||
const commands = createTurnDaemonCommandHandler({
|
||||
|
||||
Reference in New Issue
Block a user