fix: 등용장을 발송 국가 멸망 시 함께 만료
This commit is contained in:
@@ -192,6 +192,13 @@ const respondToScout = async (options: {
|
|||||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
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 sourceNationName = payload.src.nationName;
|
||||||
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
@@ -464,6 +464,11 @@ export const createReadModelChangeJournal = (
|
|||||||
if (commandResult?.type === 'voteReward' && commandResult.ok) {
|
if (commandResult?.type === 'voteReward' && commandResult.ok) {
|
||||||
journal.mark('front.general', commandResult.generalId);
|
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, 'general.content', changes.generalIds);
|
||||||
markIds(journal, 'city.content', changes.cityIds);
|
markIds(journal, 'city.content', changes.cityIds);
|
||||||
markIds(journal, 'nation.content', changes.nationIds);
|
markIds(journal, 'nation.content', changes.nationIds);
|
||||||
@@ -1910,6 +1915,37 @@ export const createDatabaseTurnHooks = async (
|
|||||||
{ sendDestOnly: message.sendDestOnly }
|
{ 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) {
|
if (options?.reservedTurns && persistedReservedTurnChanges) {
|
||||||
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => {
|
|||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: [actor],
|
generals: [actor],
|
||||||
cities: [],
|
cities: [],
|
||||||
nations: [],
|
nations: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '촉',
|
||||||
|
color: '#000000',
|
||||||
|
level: 1,
|
||||||
|
capitalCityId: 2,
|
||||||
|
chiefGeneralId: 8,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
troops: [],
|
troops: [],
|
||||||
diplomacy: [],
|
diplomacy: [],
|
||||||
events: [],
|
events: [],
|
||||||
@@ -211,6 +225,37 @@ describe('actionable message response', () => {
|
|||||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
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 () => {
|
it('treats a legacy truthy used value as an invalid scout letter', async () => {
|
||||||
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { SystemClock } from '@sammo-ts/common';
|
|||||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
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 { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||||
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
|
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
|
||||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.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 databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const worldId = 991_731;
|
const worldId = 991_731;
|
||||||
const generalId = 991_731;
|
const generalId = 731;
|
||||||
const cityId = 991_731;
|
const cityId = 731;
|
||||||
const existingNationId = 991_730;
|
const existingNationId = 730;
|
||||||
const requestId = 'integration:engine:immediate-action-uprising';
|
const requestId = 'integration:engine:immediate-action-uprising';
|
||||||
const occupiedUniqueItem = 'che_무기_12_칠성검';
|
const occupiedUniqueItem = 'che_무기_12_칠성검';
|
||||||
|
|
||||||
@@ -179,7 +181,8 @@ integration('immediate general action persistence', () => {
|
|||||||
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
|
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.city.deleteMany({ where: { id: cityId } });
|
||||||
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
@@ -288,13 +291,193 @@ integration('immediate general action persistence', () => {
|
|||||||
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
|
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.city.deleteMany({ where: { id: cityId } });
|
||||||
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
await disconnect?.();
|
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 () => {
|
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
|
||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: [general],
|
generals: [general],
|
||||||
|
|||||||
@@ -600,102 +600,126 @@ describe('my information world commands', () => {
|
|||||||
expect(nextIntInclusive).not.toHaveBeenCalled();
|
expect(nextIntInclusive).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
|
it.each([
|
||||||
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
{ recruiterNationId: 2, ruler: false },
|
||||||
const recipient = buildGeneral({
|
{ recruiterNationId: 0, ruler: false },
|
||||||
id: 8,
|
{ recruiterNationId: 3, ruler: false },
|
||||||
userId: 'user-8',
|
{ recruiterNationId: 2, ruler: true },
|
||||||
name: '재야장수',
|
])(
|
||||||
nationId: 0,
|
'checks original-nation acceptance with $recruiterNationId / ruler=$ruler',
|
||||||
cityId: 1,
|
async ({ recruiterNationId, ruler }) => {
|
||||||
officerLevel: 0,
|
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
||||||
lastTurn: originalLastTurn,
|
const recipient = buildGeneral({
|
||||||
});
|
id: 8,
|
||||||
const recruiter = buildGeneral({
|
userId: 'user-8',
|
||||||
id: 9,
|
name: '재야장수',
|
||||||
userId: 'user-9',
|
nationId: 0,
|
||||||
name: '등용장수',
|
cityId: 1,
|
||||||
nationId: 2,
|
officerLevel: 0,
|
||||||
cityId: 2,
|
lastTurn: originalLastTurn,
|
||||||
});
|
});
|
||||||
const map = {
|
const recruiter = buildGeneral({
|
||||||
id: 'test',
|
id: 9,
|
||||||
name: 'test',
|
userId: 'user-9',
|
||||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
name: '등용장수',
|
||||||
};
|
nationId: 2,
|
||||||
const fixture = buildImmediateActionWorld({
|
cityId: 2,
|
||||||
general: recipient,
|
});
|
||||||
additionalGenerals: [recruiter],
|
const map = {
|
||||||
cities: [
|
id: 'test',
|
||||||
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
name: 'test',
|
||||||
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||||
] as TurnWorldSnapshot['cities'],
|
};
|
||||||
nations: [
|
const fixture = buildImmediateActionWorld({
|
||||||
{
|
general: recipient,
|
||||||
id: 2,
|
additionalGenerals: [recruiter],
|
||||||
name: '등용국',
|
cities: [
|
||||||
color: '#222222',
|
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
||||||
typeCode: 'che_중립',
|
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
||||||
level: 1,
|
] as TurnWorldSnapshot['cities'],
|
||||||
capitalCityId: 2,
|
nations: [
|
||||||
chiefGeneralId: recruiter.id,
|
{
|
||||||
gold: 0,
|
id: 2,
|
||||||
rice: 0,
|
name: '등용국',
|
||||||
power: 0,
|
color: '#222222',
|
||||||
meta: { gennum: 1 },
|
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(
|
fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId });
|
||||||
executor.execute({
|
if (ruler) {
|
||||||
actionKey: 'che_등용수락',
|
fixture.world.updateGeneral(recipient.id, { officerLevel: 12 });
|
||||||
generalId: recipient.id,
|
const before = fixture.world.captureState();
|
||||||
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
await expect(
|
||||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
executor.execute({
|
||||||
})
|
actionKey: 'che_등용수락',
|
||||||
).resolves.toEqual({ ok: true });
|
generalId: recipient.id,
|
||||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')),
|
||||||
nationId: 2,
|
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||||
cityId: 2,
|
})
|
||||||
officerLevel: 1,
|
).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' });
|
||||||
lastTurn: originalLastTurn,
|
expect(fixture.world.captureState()).toEqual(before);
|
||||||
});
|
return;
|
||||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
}
|
||||||
experience: recruiter.experience + 100,
|
|
||||||
dedication: recruiter.dedication + 100,
|
await expect(
|
||||||
});
|
executor.execute({
|
||||||
const actionLogs = fixture.world
|
actionKey: 'che_등용수락',
|
||||||
.consumeDirtyState()
|
generalId: recipient.id,
|
||||||
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
|
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
||||||
expect(actionLogs.map((log) => log.text)).toEqual([
|
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||||
expect.stringContaining('레벨업'),
|
})
|
||||||
expect.stringContaining('승급'),
|
).resolves.toEqual({ ok: true });
|
||||||
expect.stringContaining('망명하여 수도로'),
|
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||||
expect.stringContaining('레벨업'),
|
nationId: 2,
|
||||||
expect.stringContaining('승급'),
|
cityId: 2,
|
||||||
expect.stringContaining('등용에 성공했습니다.'),
|
officerLevel: 1,
|
||||||
]);
|
lastTurn: originalLastTurn,
|
||||||
expect(actionLogs.map((log) => log.format)).toEqual([
|
});
|
||||||
LogFormat.PLAIN,
|
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||||
LogFormat.PLAIN,
|
experience: recruiter.experience + 100,
|
||||||
LogFormat.MONTH,
|
dedication: recruiter.dedication + 100,
|
||||||
LogFormat.PLAIN,
|
});
|
||||||
LogFormat.PLAIN,
|
const actionLogs = fixture.world
|
||||||
LogFormat.MONTH,
|
.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 () => {
|
it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => {
|
||||||
const recipient = buildGeneral({
|
const recipient = buildGeneral({
|
||||||
|
|||||||
Reference in New Issue
Block a user