fix: 등용장을 발송 국가 멸망 시 함께 만료
This commit is contained in:
@@ -192,6 +192,13 @@ const respondToScout = async (options: {
|
||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||
}
|
||||
|
||||
// 등용장은 발신 장수의 현재 소속이 아니라 발송 당시 국가에 귀속된다.
|
||||
// 멸망 처리 도입 전에 남은 편지도 수락/거절 전에 영구 만료한다.
|
||||
if (!world.getNationById(payload.src.nationId)) {
|
||||
await invalidateMessageIds(db, world, [row.id], now);
|
||||
return { ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' };
|
||||
}
|
||||
|
||||
const sourceNationName = payload.src.nationName;
|
||||
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
||||
if (response) {
|
||||
|
||||
@@ -464,6 +464,11 @@ export const createReadModelChangeJournal = (
|
||||
if (commandResult?.type === 'voteReward' && commandResult.ok) {
|
||||
journal.mark('front.general', commandResult.generalId);
|
||||
}
|
||||
// 과거 멸망국 등용장의 거부 응답도 action을 만료시킬 수 있다.
|
||||
// 개인 메시지 응답 뒤에는 성공 여부와 관계없이 해당 수신함을 다시 읽는다.
|
||||
if (commandResult?.type === 'messageRespond' && commandResult.action === 'scout') {
|
||||
journal.mark('messages.mailbox', commandResult.generalId);
|
||||
}
|
||||
markIds(journal, 'general.content', changes.generalIds);
|
||||
markIds(journal, 'city.content', changes.cityIds);
|
||||
markIds(journal, 'nation.content', changes.nationIds);
|
||||
@@ -1910,6 +1915,37 @@ export const createDatabaseTurnHooks = async (
|
||||
{ sendDestOnly: message.sendDestOnly }
|
||||
);
|
||||
}
|
||||
if (deletedNations.length > 0) {
|
||||
// 발신자 하야/이적은 등용장을 바꾸지 않는다. 발송 당시 국가가
|
||||
// 멸망할 때만 같은 transaction에서 action과 구버전 만료 투영을 닫는다.
|
||||
// 이번 flush에 생성된 편지도 포함하도록 message 저장 뒤에 처리한다.
|
||||
const letters = await prisma.message.findMany({
|
||||
where: {
|
||||
type: 'private',
|
||||
action: { actionType: 'scout', status: 'PENDING' },
|
||||
OR: deletedNations.map((nationId) => ({
|
||||
message: { path: ['src', 'nationId'], equals: nationId },
|
||||
})),
|
||||
},
|
||||
select: { id: true, mailbox: true },
|
||||
});
|
||||
if (letters.length > 0) {
|
||||
const ids = letters.map(({ id }) => id);
|
||||
const resolvedGameTick = BigInt(state.clockTick ?? state.lastTurnTick ?? 0);
|
||||
await prisma.messageAction.updateMany({
|
||||
where: { messageId: { in: ids }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick },
|
||||
});
|
||||
await prisma.message.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: {
|
||||
validUntil: world.gameTickToDate(Number(resolvedGameTick)),
|
||||
validUntilTick: resolvedGameTick,
|
||||
},
|
||||
});
|
||||
persistedMessageMailboxes.push(...letters.map(({ mailbox }) => mailbox));
|
||||
}
|
||||
}
|
||||
if (options?.reservedTurns && persistedReservedTurnChanges) {
|
||||
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => {
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [actor],
|
||||
cities: [],
|
||||
nations: [],
|
||||
nations: [
|
||||
{
|
||||
id: 2,
|
||||
name: '촉',
|
||||
color: '#000000',
|
||||
level: 1,
|
||||
capitalCityId: 2,
|
||||
chiefGeneralId: 8,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
typeCode: 'che_중립',
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
@@ -211,6 +225,37 @@ describe('actionable message response', () => {
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'invalidates a surviving letter from a collapsed nation on response=%s',
|
||||
async (response) => {
|
||||
const world = buildWorld();
|
||||
world.removeNation(source.nationId);
|
||||
const { db, actionUpdateMany, updateMany } = buildDb([[buildRow('scout')]]);
|
||||
const executor = buildExecutor();
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: 29,
|
||||
response,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' });
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
expect(actionUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { messageId: { in: [29] }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: expect.any(BigInt) },
|
||||
})
|
||||
);
|
||||
expect(updateMany).toHaveBeenCalledOnce();
|
||||
expect(world.getGeneralById(actor.id)?.nationId).toBe(actor.nationId);
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('treats a legacy truthy used value as an invalid scout letter', async () => {
|
||||
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
||||
const world = buildWorld();
|
||||
|
||||
@@ -4,6 +4,8 @@ import { SystemClock } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js';
|
||||
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
|
||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
@@ -17,9 +19,9 @@ import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const worldId = 991_731;
|
||||
const generalId = 991_731;
|
||||
const cityId = 991_731;
|
||||
const existingNationId = 991_730;
|
||||
const generalId = 731;
|
||||
const cityId = 731;
|
||||
const existingNationId = 730;
|
||||
const requestId = 'integration:engine:immediate-action-uprising';
|
||||
const occupiedUniqueItem = 'che_무기_12_칠성검';
|
||||
|
||||
@@ -179,7 +181,8 @@ integration('immediate general action persistence', () => {
|
||||
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
@@ -288,13 +291,193 @@ integration('immediate general action persistence', () => {
|
||||
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
await disconnect?.();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'normal',
|
||||
'resigned',
|
||||
'transferred',
|
||||
'deleted',
|
||||
'ruler',
|
||||
'collapsed',
|
||||
'legacyCollapsed',
|
||||
'random',
|
||||
] as const)('persists the recruitment-letter lifecycle: %s', async (mode) => {
|
||||
const recruiterId = generalId + 1;
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: recruiterId,
|
||||
name: '권유자',
|
||||
meta: { killturn: 24 },
|
||||
nationId: existingNationId,
|
||||
officerLevel: 1,
|
||||
cityId,
|
||||
turnTime: general.turnTime,
|
||||
},
|
||||
});
|
||||
await db.nation.update({
|
||||
where: { id: existingNationId },
|
||||
data: { capitalCityId: cityId, meta: { gennum: 1 } },
|
||||
});
|
||||
await db.city.update({ where: { id: cityId }, data: { nationId: existingNationId } });
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
const handler = createTurnDaemonCommandHandler({ world, scenarioMeta, map });
|
||||
const flush = () =>
|
||||
hooks.hooks.flushChanges!({
|
||||
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
try {
|
||||
const draft = buildScoutMessageDraft({
|
||||
srcGeneral: world.getGeneralById(recruiterId)!,
|
||||
destGeneral: world.getGeneralById(generalId)!,
|
||||
srcNation: world.getNationById(existingNationId),
|
||||
destNation: null,
|
||||
time: world.gameTickToDate(0),
|
||||
});
|
||||
expect(draft).not.toBeNull();
|
||||
world.queueMessage(draft!);
|
||||
await flush();
|
||||
const letter = await db.message.findFirstOrThrow({
|
||||
where: { mailbox: generalId },
|
||||
include: { action: true },
|
||||
});
|
||||
expect(letter.action?.status).toBe('PENDING');
|
||||
const envelopeWallTime = letter.createdAtWall;
|
||||
if (mode === 'collapsed') {
|
||||
world.queueMessage({
|
||||
...draft!,
|
||||
src: { ...draft!.src, nationId: existingNationId + 10 },
|
||||
text: '다른 국가의 등용장',
|
||||
});
|
||||
world.queueMessage({ ...draft!, option: {}, text: '일반 서신' });
|
||||
await flush();
|
||||
}
|
||||
while (hooks.takeCommittedReadModelChangeReceipt()) {
|
||||
/* discard setup receipts */
|
||||
}
|
||||
|
||||
if (mode === 'resigned' || mode === 'transferred') {
|
||||
world.updateGeneral(recruiterId, { nationId: mode === 'resigned' ? 0 : existingNationId + 10 });
|
||||
} else if (mode === 'deleted') {
|
||||
world.removeGeneral(recruiterId);
|
||||
} else if (mode === 'ruler') {
|
||||
world.updateGeneral(generalId, { nationId: existingNationId + 10, officerLevel: 12 });
|
||||
} else if (mode === 'random') {
|
||||
world.updateWorldConfig({ joinMode: 'onlyRandom' });
|
||||
} else if (mode === 'collapsed' || mode === 'legacyCollapsed') {
|
||||
world.removeNation(existingNationId);
|
||||
}
|
||||
if (mode === 'collapsed') {
|
||||
// Force failure after nation deletion: the envelope/action and nation must roll back together.
|
||||
await db.$executeRawUnsafe(
|
||||
"ALTER TABLE message_action ADD CONSTRAINT scout_test_pending CHECK (status = 'PENDING')"
|
||||
);
|
||||
await expect(flush()).rejects.toThrow();
|
||||
expect(await db.nation.findUnique({ where: { id: existingNationId } })).not.toBeNull();
|
||||
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
|
||||
'PENDING'
|
||||
);
|
||||
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT scout_test_pending');
|
||||
}
|
||||
await flush();
|
||||
if (mode === 'legacyCollapsed') {
|
||||
// Simulate an old deployment which deleted the nation but left this action pending.
|
||||
await db.messageAction.update({
|
||||
where: { messageId: letter.id },
|
||||
data: { status: 'PENDING', resolvedGameTick: null },
|
||||
});
|
||||
}
|
||||
const currentLetter = await db.message.findUniqueOrThrow({
|
||||
where: { id: letter.id },
|
||||
include: { action: true },
|
||||
});
|
||||
expect(currentLetter.createdAtWall).toEqual(envelopeWallTime);
|
||||
expect(currentLetter.action?.status).toBe(mode === 'collapsed' ? 'RESOLVED' : 'PENDING');
|
||||
if (mode === 'collapsed') {
|
||||
expect(currentLetter.validUntilTick).not.toBeNull();
|
||||
const receipt = hooks.takeCommittedReadModelChangeReceipt();
|
||||
expect(receipt?.invalidation.revisions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
domain: 'messages.mailbox',
|
||||
entityId: generalId,
|
||||
})
|
||||
);
|
||||
const controls = await db.message.findMany({
|
||||
where: { mailbox: generalId, id: { not: letter.id } },
|
||||
include: { action: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(controls.map((entry) => entry.action?.status ?? null)).toEqual(['PENDING', null]);
|
||||
expect(controls.every((entry) => entry.validUntil.getUTCFullYear() === 9999)).toBe(true);
|
||||
}
|
||||
const actionRequestId = `${requestId}:scout:${mode}`;
|
||||
const payload = {
|
||||
type: 'messageRespond' as const,
|
||||
requestId: actionRequestId,
|
||||
userId: general.userId!,
|
||||
generalId,
|
||||
messageId: letter.id,
|
||||
response: true,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId: actionRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'messageRespond',
|
||||
actorUserId: general.userId,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await queue.initialize();
|
||||
const commands = await queue.drain();
|
||||
expect(commands).toHaveLength(1);
|
||||
const result = await hooks.hooks.executeCommand!(actionRequestId, async (ctx) => {
|
||||
const value = await handler.handle(commands[0]!, ctx);
|
||||
if (!value) throw new Error('missing message response');
|
||||
return value;
|
||||
});
|
||||
if (mode === 'legacyCollapsed') {
|
||||
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toContainEqual(
|
||||
expect.objectContaining({ domain: 'messages.mailbox', entityId: generalId })
|
||||
);
|
||||
}
|
||||
const accepted = ['normal', 'resigned', 'transferred', 'deleted'].includes(mode);
|
||||
expect(result).toMatchObject(
|
||||
accepted ? { ok: true, reason: 'success' } : { reason: expect.not.stringMatching(/^success$/) }
|
||||
);
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(reloaded.snapshot.generals.find(({ id }) => id === generalId)).toMatchObject({
|
||||
nationId: accepted ? existingNationId : mode === 'ruler' ? existingNationId + 10 : 0,
|
||||
officerLevel: accepted ? 1 : mode === 'ruler' ? 12 : 0,
|
||||
});
|
||||
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
|
||||
mode === 'ruler' || mode === 'random' ? 'PENDING' : 'RESOLVED'
|
||||
);
|
||||
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId: actionRequestId } })).status).toBe(
|
||||
'SUCCEEDED'
|
||||
);
|
||||
expect(await queue.drain()).toEqual([]);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT IF EXISTS scout_test_pending');
|
||||
await hooks.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
|
||||
@@ -600,102 +600,126 @@ describe('my information world commands', () => {
|
||||
expect(nextIntInclusive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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',
|
||||
name: '재야장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const recruiter = buildGeneral({
|
||||
id: 9,
|
||||
userId: 'user-9',
|
||||
name: '등용장수',
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
});
|
||||
const map = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||
};
|
||||
const fixture = buildImmediateActionWorld({
|
||||
general: recipient,
|
||||
additionalGenerals: [recruiter],
|
||||
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: recruiter.id,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
meta: { gennum: 1 },
|
||||
it.each([
|
||||
{ recruiterNationId: 2, ruler: false },
|
||||
{ recruiterNationId: 0, ruler: false },
|
||||
{ recruiterNationId: 3, ruler: false },
|
||||
{ recruiterNationId: 2, ruler: true },
|
||||
])(
|
||||
'checks original-nation acceptance with $recruiterNationId / ruler=$ruler',
|
||||
async ({ recruiterNationId, ruler }) => {
|
||||
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
||||
const recipient = buildGeneral({
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
name: '재야장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const recruiter = buildGeneral({
|
||||
id: 9,
|
||||
userId: 'user-9',
|
||||
name: '등용장수',
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
});
|
||||
const map = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||
};
|
||||
const fixture = buildImmediateActionWorld({
|
||||
general: recipient,
|
||||
additionalGenerals: [recruiter],
|
||||
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: recruiter.id,
|
||||
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: [],
|
||||
},
|
||||
] 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-recruitment-letter')),
|
||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||
experience: recruiter.experience + 100,
|
||||
dedication: recruiter.dedication + 100,
|
||||
});
|
||||
const actionLogs = fixture.world
|
||||
.consumeDirtyState()
|
||||
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
|
||||
expect(actionLogs.map((log) => log.text)).toEqual([
|
||||
expect.stringContaining('레벨업'),
|
||||
expect.stringContaining('승급'),
|
||||
expect.stringContaining('망명하여 수도로'),
|
||||
expect.stringContaining('레벨업'),
|
||||
expect.stringContaining('승급'),
|
||||
expect.stringContaining('등용에 성공했습니다.'),
|
||||
]);
|
||||
expect(actionLogs.map((log) => log.format)).toEqual([
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.MONTH,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.MONTH,
|
||||
]);
|
||||
});
|
||||
fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId });
|
||||
if (ruler) {
|
||||
fixture.world.updateGeneral(recipient.id, { officerLevel: 12 });
|
||||
const before = fixture.world.captureState();
|
||||
await expect(
|
||||
executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: recipient.id,
|
||||
rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')),
|
||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' });
|
||||
expect(fixture.world.captureState()).toEqual(before);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(
|
||||
executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: recipient.id,
|
||||
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||
experience: recruiter.experience + 100,
|
||||
dedication: recruiter.dedication + 100,
|
||||
});
|
||||
const actionLogs = fixture.world
|
||||
.consumeDirtyState()
|
||||
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
|
||||
expect(actionLogs.map((log) => log.text)).toEqual([
|
||||
expect.stringContaining('레벨업'),
|
||||
expect.stringContaining('승급'),
|
||||
expect.stringContaining('망명하여 수도로'),
|
||||
expect.stringContaining('레벨업'),
|
||||
expect.stringContaining('승급'),
|
||||
expect.stringContaining('등용에 성공했습니다.'),
|
||||
]);
|
||||
expect(actionLogs.map((log) => log.format)).toEqual([
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.MONTH,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.MONTH,
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => {
|
||||
const recipient = buildGeneral({
|
||||
|
||||
Reference in New Issue
Block a user