diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 24b2b3b..9f40fbb 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -17,7 +17,7 @@ import type { GeneralActionResolver, GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect, createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import { z } from 'zod'; @@ -29,8 +29,8 @@ import { parseArgsWithSchema } from '../parseArgs.js'; const ACTION_NAME = '등용수락'; const ACTION_KEY = 'che_등용수락'; const ARGS_SCHEMA = z.object({ - destNationId: z.number(), - destGeneralId: z.number(), + destNationId: z.number().int().positive(), + destGeneralId: z.number().int().positive(), }); export type AcceptScoutArgs = z.infer; @@ -39,8 +39,21 @@ export interface AcceptScoutResolveContext< > extends GeneralActionResolveContext { destNation?: Nation; destGeneral?: General; + worldKillturn?: number; } +const differentDestGeneral = (destGeneralId: number): Constraint => ({ + name: 'differentDestGeneral', + requires: (ctx) => [ + { kind: 'general', id: ctx.actorId }, + { kind: 'destGeneral', id: destGeneralId }, + ], + test: (ctx) => + ctx.actorId === destGeneralId + ? { kind: 'deny', reason: '본인의 등용장을 수락할 수 없습니다.' } + : { kind: 'allow' }, +}); + export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { @@ -77,7 +90,7 @@ export class ActionResolver< // Global Log context.addLog(`${generalName}${josaYi} ${destNationName}${josaRo} 망명하였습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, format: LogFormat.PLAIN, }); @@ -97,6 +110,9 @@ export class ActionResolver< 0, Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10)) ); + const previousRecruiterExpLevel = typeof destGeneral.meta.explevel === 'number' ? destGeneral.meta.explevel : 0; + const previousRecruiterDedicationLevel = + typeof destGeneral.meta.dedlevel === 'number' ? destGeneral.meta.dedlevel : 0; effects.push( createGeneralPatchEffect( { @@ -111,6 +127,43 @@ export class ActionResolver< destGeneral.id ) ); + if (recruiterExpLevel !== previousRecruiterExpLevel) { + const josaRo = JosaUtil.pick(String(recruiterExpLevel), '로'); + effects.push( + createLogEffect( + recruiterExpLevel > previousRecruiterExpLevel + ? `Lv ${recruiterExpLevel}${josaRo} 레벨업!` + : `Lv ${recruiterExpLevel}${josaRo} 레벨다운!`, + { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + } + ) + ); + } + if (recruiterDedicationLevel !== previousRecruiterDedicationLevel) { + const maxDedicationLevel = this.env.maxDedicationLevel ?? 30; + const dedicationLevelText = + recruiterDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - recruiterDedicationLevel + 1}품관`; + const billText = new Intl.NumberFormat('en-US').format(recruiterDedicationLevel * 200 + 400); + const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); + const josaRoBill = JosaUtil.pick(billText, '로'); + effects.push( + createLogEffect( + recruiterDedicationLevel > previousRecruiterDedicationLevel + ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` + : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, + { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + } + ) + ); + } // 3. Betrayal Logic // Legacy: GameConst::$defaultGold (usually 1000/2000). @@ -187,10 +240,7 @@ export class ActionResolver< betray: newBetray, ...(general.npcState < 2 ? { - killturn: - typeof context.general.meta.killturn === 'number' - ? context.general.meta.killturn - : 0, + killturn: context.worldKillturn ?? general.meta.killturn, } : {}), }, @@ -266,12 +316,12 @@ export class ActionDefinition< buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] { const env = _ctx.env; const year = typeof env.year === 'number' ? env.year : 0; - const startYear = typeof env.startyear === 'number' ? env.startyear : 0; - const relYear = year - startYear; + const relYear = typeof env.relYear === 'number' ? env.relYear : year; return [ reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), existsDestNation(), + differentDestGeneral(_args.destGeneralId), beNeutral(), allowJoinDestNation(relYear), reqDestNationValue('level', '국가규모', '>', 0, '방랑군에는 임관할 수 없습니다.'), @@ -304,6 +354,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { ...base, destNation, destGeneral, + worldKillturn: typeof options.world.meta?.killturn === 'number' ? options.world.meta.killturn : undefined, }; }; diff --git a/packages/logic/src/actions/turn/general/che_랜덤임관.ts b/packages/logic/src/actions/turn/general/che_랜덤임관.ts index 7ada210..1b27769 100644 --- a/packages/logic/src/actions/turn/general/che_랜덤임관.ts +++ b/packages/logic/src/actions/turn/general/che_랜덤임관.ts @@ -236,7 +236,6 @@ export class ActionDefinition< nationId: destNation.id, officerLevel: 1, cityId: destCityId, - troopId: 0, experience: general.experience + expGain, meta, }), @@ -263,13 +262,17 @@ export class ActionDefinition< `${generalName}${josaYi} ${talk} ${destNationName}임관했습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, format: LogFormat.PLAIN, } ) ); - tryApplyUniqueLottery(context, { acquireType: '랜덤 임관', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { + acquireType: '랜덤 임관', + reason: ACTION_NAME, + nationName: destNationName, + }); return { effects }; } @@ -327,7 +330,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { if (nationGenerals.length === 0) { continue; } - if (typeof genLimit === 'number' && genLimit > 0 && nationGenerals.length >= genLimit) { + const generalCount = resolveNumber(meta, ['gennum'], nationGenerals.length); + if (typeof genLimit === 'number' && genLimit > 0 && generalCount >= genLimit) { continue; } let monarchCityId = monarchCityByNation.get(nation.id) ?? null; @@ -347,7 +351,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { candidateNations.push({ nation, generals: nationGenerals, - generalCount: resolveNumber(meta, ['gennum'], nationGenerals.length), + generalCount, monarchCityId, monarchAffinity: chief ? resolveNumber(asRecord(chief.meta), ['affinity'], 0) : 0, }); diff --git a/packages/logic/src/actions/turn/general/che_임관.ts b/packages/logic/src/actions/turn/general/che_임관.ts index 76a7891..6c4e98c 100644 --- a/packages/logic/src/actions/turn/general/che_임관.ts +++ b/packages/logic/src/actions/turn/general/che_임관.ts @@ -26,7 +26,7 @@ import { JosaUtil } from '@sammo-ts/common'; const ACTION_NAME = '임관'; const ARGS_SCHEMA = z.object({ - destNationId: z.number(), + destNationId: z.number().int().positive(), }); export type AppointmentArgs = z.infer; @@ -38,13 +38,6 @@ interface AppointmentContext< destCityId: number; } -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - return raw > 0 ? Math.floor(raw) : null; -}; - export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition { @@ -57,15 +50,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): AppointmentArgs | null { - const data = parseArgsWithSchema(ARGS_SCHEMA, raw); - if (!data) { - return null; - } - const destNationId = parseNationId(data.destNationId); - if (destNationId === null) { - return null; - } - return { destNationId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildMinConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { @@ -109,11 +94,15 @@ export class ActionDefinition< const josaYi = JosaUtil.pick(context.general.name, '이'); context.addLog(`${context.general.name}${josaYi} ${destNationName}임관했습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, format: LogFormat.RAWTEXT, }); - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { + acquireType: '아이템', + reason: ACTION_NAME, + nationName: destNationName, + }); const effects: GeneralActionOutcome['effects'] = [ createGeneralPatchEffect({ @@ -164,10 +153,14 @@ export const actionContextBuilder: ActionContextBuilder = ( } const nationGenerals = worldRef.listGenerals().filter((general) => general.nationId === destNation.id); const monarch = nationGenerals.find((general) => general.officerLevel === 12); + const cachedGeneralCount = destNation.meta.gennum; return { ...base, destNation, - destNationGeneralCount: nationGenerals.length, + destNationGeneralCount: + typeof cachedGeneralCount === 'number' && Number.isFinite(cachedGeneralCount) + ? cachedGeneralCount + : nationGenerals.length, destCityId: monarch?.cityId ?? destNation.capitalCityId ?? base.general.cityId, }; }; diff --git a/packages/logic/src/actions/turn/general/che_장수대상임관.ts b/packages/logic/src/actions/turn/general/che_장수대상임관.ts index 0cef19d..651f3d1 100644 --- a/packages/logic/src/actions/turn/general/che_장수대상임관.ts +++ b/packages/logic/src/actions/turn/general/che_장수대상임관.ts @@ -47,16 +47,21 @@ const existsDestNation = (destGeneralID: number): Constraint => ({ const allowJoinDestNation = (destGeneralID: number): Constraint => ({ name: 'allowJoinDestNation', - requires: () => [ + requires: (ctx) => [ + { kind: 'general', id: ctx.actorId }, { kind: 'destGeneral', id: destGeneralID }, - { kind: 'generalList' }, { kind: 'nationList' }, { kind: 'env', key: 'relYear' }, { kind: 'env', key: 'openingPartYear' }, { kind: 'env', key: 'initialNationGenLimit' }, - { kind: 'env', key: 'maxGeneral' }, ], test: (ctx, view) => { + const actorReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + const actor = view.get(actorReq) as General | null; + if (!actor) { + return unknownOrDeny(ctx, [actorReq], '장수 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'destGeneral', id: destGeneralID }; if (!view.has(req)) { return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); @@ -66,15 +71,6 @@ const allowJoinDestNation = (destGeneralID: number): Constraint => ({ return { kind: 'deny', reason: '국가 정보가 없습니다.' }; } - const listReq: RequirementKey = { kind: 'generalList' }; - if (!view.has(listReq)) { - return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.'); - } - const generals = view.get(listReq) as General[] | null; - if (!generals) { - return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.'); - } - const nationListReq: RequirementKey = { kind: 'nationList' }; if (!view.has(nationListReq)) { return unknownOrDeny(ctx, [nationListReq], '국가 정보가 없습니다.'); @@ -100,28 +96,28 @@ const allowJoinDestNation = (destGeneralID: number): Constraint => ({ typeof view.get({ kind: 'env', key: 'initialNationGenLimit' }) === 'number' ? (view.get({ kind: 'env', key: 'initialNationGenLimit' }) as number) : 10; - const maxGeneral = - typeof view.get({ kind: 'env', key: 'maxGeneral' }) === 'number' - ? (view.get({ kind: 'env', key: 'maxGeneral' }) as number) - : 500; - - const currentCount = generals.filter((general) => general.nationId === destNation.id).length; - - if (destNation.level === 0) { - return { kind: 'allow' }; + const rawGeneralCount = destNation.meta.gennum; + const generalCount = + typeof rawGeneralCount === 'number' + ? rawGeneralCount + : typeof rawGeneralCount === 'string' + ? Number(rawGeneralCount) + : 0; + if (relYear < openingPartYear && generalCount >= initialNationGenLimit) { + return { kind: 'deny', reason: '임관이 제한되고 있습니다.' }; } - if (relYear < openingPartYear) { - if (currentCount < initialNationGenLimit) { - return { kind: 'allow' }; - } - return { kind: 'deny', reason: '초반 등용 제한 인원을 초과했습니다.' }; + const rawScout = destNation.meta.scout; + if (rawScout === 1 || rawScout === '1' || rawScout === true) { + return { kind: 'deny', reason: '임관이 금지되어 있습니다.' }; } - - if (currentCount < maxGeneral) { - return { kind: 'allow' }; + if (actor.npcState < 2 && destNation.name.startsWith('ⓤ')) { + return { kind: 'deny', reason: '유저장은 태수국에 임관할 수 없습니다.' }; } - return { kind: 'deny', reason: '등용 제한 인원을 초과했습니다.' }; + if (actor.npcState !== 9 && destNation.name.startsWith('ⓞ')) { + return { kind: 'deny', reason: '이민족 국가에 임관할 수 없습니다.' }; + } + return { kind: 'allow' }; }, }); @@ -182,14 +178,18 @@ export class ActionDefinition< }); context.addLog(`${general.name}${josaYi} ${destNation.name}임관했습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, }); const initialNationGenLimit = context.initialNationGenLimit ?? 10; const destNationGeneralCount = context.destNationGeneralCount ?? 0; const expGain = destNationGeneralCount < initialNationGenLimit ? 700 : 100; - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { + acquireType: '아이템', + reason: ACTION_NAME, + nationName: destNation.name, + }); return { effects: [ @@ -197,7 +197,6 @@ export class ActionDefinition< nationId: destNation.id, officerLevel: 1, cityId: targetCityId, - troopId: 0, experience: general.experience + expGain, meta: { ...general.meta, @@ -239,7 +238,9 @@ export const actionContextBuilder: ActionContextBuilder = return null; } - const currentCount = worldRef.listGenerals().filter((general) => general.nationId === destNation.id).length; + const actualCount = worldRef.listGenerals().filter((general) => general.nationId === destNation.id).length; + const cachedCount = destNation.meta.gennum; + const currentCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount) ? cachedCount : actualCount; const constValues = (options.scenarioConfig.const ?? {}) as Record; const initialNationGenLimitRaw = constValues.initialNationGenLimit; diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index 1f303ef..9e52cfe 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -329,11 +329,7 @@ export const allowRebellion = (): Constraint => ({ } const rawKillturn = view.get(envReq); const worldKillturn = - typeof rawKillturn === 'number' - ? rawKillturn - : typeof rawKillturn === 'string' - ? Number(rawKillturn) - : NaN; + typeof rawKillturn === 'number' ? rawKillturn : typeof rawKillturn === 'string' ? Number(rawKillturn) : NaN; if (!Number.isFinite(worldKillturn)) { return unknownOrDeny(ctx, [envReq], '턴 정보가 없습니다.'); } @@ -395,7 +391,6 @@ export const allowJoinDestNation = (relYear: number): Constraint => ({ { kind: 'general', id: ctx.actorId }, { kind: 'env', key: 'openingPartYear' }, { kind: 'env', key: 'initialNationGenLimit' }, - { kind: 'env', key: 'maxGeneral' }, ]; const destNationId = resolveDestNationId(ctx); if (destNationId !== undefined) { @@ -428,17 +423,13 @@ export const allowJoinDestNation = (relYear: number): Constraint => ({ const openingPartYear = view.get({ kind: 'env', key: 'openingPartYear' }) as number | null; const initialNationGenLimit = view.get({ kind: 'env', key: 'initialNationGenLimit' }) as number | null; - const defaultMaxGeneral = view.get({ kind: 'env', key: 'maxGeneral' }) as number | null; - const gennum = readNationNumeric(destNation, 'gennum') ?? 0; const scout = readNationNumeric(destNation, 'scout') ?? 0; const name = readStringField(destNation, 'name') ?? ''; const openingLimit = typeof openingPartYear === 'number' ? openingPartYear : 0; const initialLimit = typeof initialNationGenLimit === 'number' ? initialNationGenLimit : 0; - const normalLimit = typeof defaultMaxGeneral === 'number' ? defaultMaxGeneral : initialLimit; - const genLimit = relYear < openingLimit ? initialLimit : normalLimit; - if (genLimit > 0 && gennum >= genLimit) { + if (relYear < openingLimit && initialLimit > 0 && gennum >= initialLimit) { return { kind: 'deny', reason: '임관이 제한되고 있습니다.' }; } diff --git a/packages/logic/src/rewards/uniqueLottery.ts b/packages/logic/src/rewards/uniqueLottery.ts index 2bb9809..9988236 100644 --- a/packages/logic/src/rewards/uniqueLottery.ts +++ b/packages/logic/src/rewards/uniqueLottery.ts @@ -13,6 +13,7 @@ export type UniqueAcquireType = (typeof UNIQUE_ACQUIRE_TYPES)[number]; export type UniqueLotteryRequest = { acquireType: UniqueAcquireType; reason: string; + nationName?: string; }; export type UniqueLotteryRunner = ( @@ -324,10 +325,11 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { export const applyUniqueItemGain = ( context: GeneralActionResolveContext, itemModule: ItemModule, - acquireType: UniqueAcquireType + acquireType: UniqueAcquireType, + nationNameOverride?: string ): void => { const general = context.general; - const nationName = context.nation?.name ?? '재야'; + const nationName = nationNameOverride ?? context.nation?.name ?? '재야'; const generalName = general.name; const itemName = itemModule.name; const itemRawName = itemModule.rawName; @@ -375,6 +377,6 @@ export const tryApplyUniqueLottery = ; + actorPatch?: Record; + fixturePatches?: FixturePatches; + completed: boolean; + expectedActionKey?: string; + restoresWorldKillTurn?: boolean; +}; + +const generalAppointmentBoundaryCases: GeneralAppointmentBoundaryCase[] = [ + { + name: 'direct appointment rejects a fractional nation ID', + action: 'che_임관', + args: { destNationID: 1.9 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + completed: false, + }, + { + name: 'direct appointment rejects the cached opening general limit', + action: 'che_임관', + args: { destNationID: 1 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 182 }, + nations: { 1: { generalCount: 10 } }, + }, + completed: false, + }, + { + name: 'direct appointment has no normal-era max-general constraint', + action: 'che_임관', + args: { destNationID: 1 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 183 }, + nations: { 1: { generalCount: 500 } }, + generals: { 3: { officerLevel: 12, officerCityId: 3 } }, + }, + completed: true, + }, + { + name: 'direct appointment blocks user generals from governor nations', + action: 'che_임관', + args: { destNationID: 1 }, + actorPatch: { nationId: 0, officerLevel: 0, npcState: 0 }, + fixturePatches: { nations: { 1: { name: 'ⓤ아국' } } }, + completed: false, + }, + { + name: 'direct appointment blocks ordinary NPCs from outsider nations', + action: 'che_임관', + args: { destNationID: 1 }, + actorPatch: { nationId: 0, officerLevel: 0, npcState: 2 }, + fixturePatches: { nations: { 1: { name: 'ⓞ아국' } } }, + completed: false, + }, + { + name: 'direct appointment permits outsider NPC type nine', + action: 'che_임관', + args: { destNationID: 1 }, + actorPatch: { nationId: 0, officerLevel: 0, npcState: 9 }, + fixturePatches: { + nations: { 1: { name: 'ⓞ아국' } }, + generals: { 3: { officerLevel: 12, officerCityId: 3 } }, + }, + completed: true, + }, + { + name: 'follow appointment uses cached general count at the opening limit', + action: 'che_장수대상임관', + args: { destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 182 }, + nations: { 2: { generalCount: 10 } }, + }, + completed: false, + }, + { + name: 'follow appointment blocks user generals from governor nations', + action: 'che_장수대상임관', + args: { destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0, npcState: 0 }, + fixturePatches: { nations: { 2: { name: 'ⓤ타국' } } }, + completed: false, + }, + { + name: 'follow appointment blocks ordinary NPCs from outsider nations', + action: 'che_장수대상임관', + args: { destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0, npcState: 2 }, + fixturePatches: { nations: { 2: { name: 'ⓞ타국' } } }, + completed: false, + }, + { + name: 'follow appointment preserves the legacy troop field', + action: 'che_장수대상임관', + args: { destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0, troopId: 7 }, + completed: true, + }, + { + name: 'random appointment uses cached general counts during the opening', + action: 'che_랜덤임관', + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 182 }, + nations: { 1: { generalCount: 10 }, 2: { generalCount: 10 } }, + }, + completed: false, + expectedActionKey: 'che_인재탐색', + }, + { + name: 'random appointment preserves the legacy troop field', + action: 'che_랜덤임관', + actorPatch: { nationId: 0, officerLevel: 0, troopId: 7 }, + fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3 } } }, + completed: true, + }, + { + name: 'random appointment uses cached general counts at the normal limit', + action: 'che_랜덤임관', + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 183 }, + nations: { 1: { generalCount: 500 }, 2: { generalCount: 500 } }, + }, + completed: false, + expectedActionKey: 'che_인재탐색', + }, + { + name: 'employment rejects a same-nation target', + action: 'che_등용', + args: { destGeneralID: 3 }, + completed: false, + }, + { + name: 'employment rejects a target monarch', + action: 'che_등용', + args: { destGeneralID: 2 }, + completed: false, + }, + { + name: 'employment rejects a neutral actor', + action: 'che_등용', + args: { destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { generals: { 2: { officerLevel: 1 } } }, + completed: false, + }, + { + name: 'employment rejects an unsupplied actor city', + action: 'che_등용', + args: { destGeneralID: 2 }, + fixturePatches: { + generals: { 2: { officerLevel: 1 } }, + cities: { 3: { supplyState: 0 } }, + }, + completed: false, + }, + { + name: 'accepting employment applies the opening cached limit', + action: 'che_등용수락', + args: { destNationID: 2, destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 182 }, + nations: { 2: { generalCount: 10 } }, + }, + completed: false, + }, + { + name: 'accepting employment has no normal-era max-general constraint', + action: 'che_등용수락', + args: { destNationID: 2, destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 183 }, + nations: { 2: { generalCount: 500 } }, + }, + completed: true, + }, + { + name: 'accepting employment rejects the actor as recruiter', + action: 'che_등용수락', + args: { destNationID: 2, destGeneralID: 1 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + completed: false, + }, + { + name: 'accepting employment rejects a wandering destination nation', + action: 'che_등용수락', + args: { destNationID: 2, destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { nations: { 2: { level: 0 } } }, + completed: false, + }, + { + name: 'accepting employment restores the user kill-turn allowance', + action: 'che_등용수락', + args: { destNationID: 2, destGeneralID: 2 }, + actorPatch: { nationId: 0, officerLevel: 0, npcState: 0, killTurn: 5 }, + completed: true, + restoresWorldKillTurn: true, + }, +]; + +integration('general appointment and employment boundary, state, RNG, and log parity', () => { + it.each(generalAppointmentBoundaryCases)( + '$name', + async ({ + name, + action, + args, + actorPatch, + fixturePatches, + completed, + expectedActionKey, + restoresWorldKillTurn, + }) => { + const request = buildRequest(action, args, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = `general-appointment-${name}`; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const actionKey = expectedActionKey ?? (completed ? action : '휴식'); + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey, + usedFallback: !completed && actionKey === '휴식', + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (completed) { + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + if (restoresWorldKillTurn) { + const expectedKillTurn = reference.before.world.killTurn; + expect(reference.after.generals.find((entry) => entry.id === 1)?.killTurn).toBe(expectedKillTurn); + expect(core.after.generals.find((entry) => entry.id === 1)?.killTurn).toBe(expectedKillTurn); + } + }, + 120_000 + ); +}); + integration('명장일람 rank_data command parity', () => { it('화계 increments firenum from the same seeded value as legacy', async () => { const request = buildRequest(