fix general appointment command parity

This commit is contained in:
2026-07-26 16:53:28 +00:00
parent ec941ad173
commit a0df7f5b3c
7 changed files with 385 additions and 83 deletions
@@ -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<typeof ARGS_SCHEMA>;
@@ -39,8 +39,21 @@ export interface AcceptScoutResolveContext<
> extends GeneralActionResolveContext<TriggerState> {
destNation?: Nation;
destGeneral?: General<TriggerState>;
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<TriggerState, AcceptScoutArgs> {
@@ -77,7 +90,7 @@ export class ActionResolver<
// Global Log
context.addLog(`<Y>${generalName}</>${josaYi} <D><b>${destNationName}</b></>${josaRo} <S>망명</>하였습니다.`, {
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
? `<C>Lv ${recruiterExpLevel}</>${josaRo} <C>레벨업</>!`
: `<C>Lv ${recruiterExpLevel}</>${josaRo} <R>레벨다운</>!`,
{
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
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
{
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,
};
};
@@ -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<
`<Y>${generalName}</>${josaYi} ${talk} <D><b>${destNationName}</b></>에 <S>임관</>했습니다.`,
{
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,
});
@@ -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<typeof ARGS_SCHEMA>;
@@ -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<TriggerState, AppointmentArgs> {
@@ -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(`<Y>${context.general.name}</>${josaYi} <D><b>${destNationName}</b></>에 <S>임관</>했습니다.`, {
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<TriggerState>['effects'] = [
createGeneralPatchEffect<TriggerState>({
@@ -164,10 +153,14 @@ export const actionContextBuilder: ActionContextBuilder<AppointmentArgs> = (
}
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,
};
};
@@ -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(`<Y>${general.name}</>${josaYi} <D><b>${destNation.name}</b></>에 <S>임관</>했습니다.`, {
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<FollowAppointmentArgs> =
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<string, unknown>;
const initialNationGenLimitRaw = constValues.initialNationGenLimit;
+2 -11
View File
@@ -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: '임관이 제한되고 있습니다.' };
}
+5 -3
View File
@@ -13,6 +13,7 @@ export type UniqueAcquireType = (typeof UNIQUE_ACQUIRE_TYPES)[number];
export type UniqueLotteryRequest = {
acquireType: UniqueAcquireType;
reason: string;
nationName?: string;
};
export type UniqueLotteryRunner = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
@@ -324,10 +325,11 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
export const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
context: GeneralActionResolveContext<TriggerState>,
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 = <TriggerState extends GeneralTriggerState =
if (!itemModule) {
return false;
}
applyUniqueItemGain(context, itemModule, request.acquireType);
applyUniqueItemGain(context, itemModule, request.acquireType, request.nationName);
return true;
};
@@ -1665,6 +1665,266 @@ integration('general training, talent scout, and spy boundary, value, RNG, and l
);
});
type GeneralAppointmentBoundaryCase = {
name: string;
action: 'che_임관' | 'che_랜덤임관' | 'che_장수대상임관' | 'che_등용' | 'che_등용수락';
args?: Record<string, unknown>;
actorPatch?: Record<string, unknown>;
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<string, unknown>
);
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(