fix general status transition parity

This commit is contained in:
2026-07-26 17:12:54 +00:00
parent a0df7f5b3c
commit 80a2c9502f
10 changed files with 238 additions and 17 deletions
@@ -847,6 +847,7 @@ export const createReservedTurnHandler = async (options: {
}> = [];
const createdGenerals: TurnGeneral[] = [];
const createdNations: Nation[] = [];
const commandDeletedTroopIds = new Set<number>();
let currentGeneral = context.general;
let currentCity = context.city;
@@ -1092,6 +1093,9 @@ export const createReservedTurnHandler = async (options: {
},
actionArgs
);
for (const troopId of resolution.deletedTroopIds ?? []) {
commandDeletedTroopIds.add(troopId);
}
currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity;
@@ -1599,7 +1603,7 @@ export const createReservedTurnHandler = async (options: {
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active';
let deleteGeneral = false;
const deletedTroopIds: number[] = [];
const deletedTroopIds = Array.from(commandDeletedTroopIds);
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
if (currentGeneral.meta.killturn <= 0 && typeof currentGeneral.deadYear === 'number') {
if (
@@ -1752,10 +1756,10 @@ export const createReservedTurnHandler = async (options: {
...(createdNations.length > 0 ? { nations: createdNations } : {}),
}
: undefined,
...(deleteGeneral
...(deleteGeneral || deletedTroopIds.length > 0
? {
deleted: {
general: true,
general: deleteGeneral,
...(deletedTroopIds.length > 0 ? { troopIds: deletedTroopIds } : {}),
},
}
@@ -68,7 +68,10 @@ const makeGeneral = (patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
...patch,
});
const makeSnapshot = (general: TurnGeneral): TurnWorldSnapshot => ({
const makeSnapshot = (
general: TurnGeneral,
extras: { generals?: TurnGeneral[]; troops?: TurnWorldSnapshot['troops'] } = {}
): TurnWorldSnapshot => ({
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
@@ -132,8 +135,8 @@ const makeSnapshot = (general: TurnGeneral): TurnWorldSnapshot => ({
meta: { trust: 50, trade: 100, region: 1 },
},
],
generals: [general],
troops: [],
generals: [general, ...(extras.generals ?? [])],
troops: extras.troops ?? [],
diplomacy: [],
events: [],
initialEvents: [],
@@ -248,4 +251,33 @@ describe('legacy general-turn execution contract', () => {
expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식');
expect(harness.getCollectedLogs().some((log) => log.text.includes('악성유저'))).toBe(true);
});
it('deletes the troop row and releases members when a troop leader resigns', async () => {
const leader = makeGeneral({ troopId: 1 });
const member = makeGeneral({
id: 2,
name: '부대원',
troopId: 1,
turnTime: new Date(start.getTime() + 10 * 60_000),
});
const snapshot = makeSnapshot(leader, {
generals: [member],
troops: [{ id: 1, nationId: 1, name: '테스트군' }],
});
snapshot.nations[0]!.meta.gennum = 2;
const harness = await createTurnTestHarness({
snapshot,
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_하야', args: {} };
await harness.runOneTick({ maxGenerals: 1 });
expect(harness.world.getGeneralById(1)?.troopId).toBe(0);
expect(harness.world.getGeneralById(2)?.troopId).toBe(0);
expect(harness.world.getTroopById(1)).toBeNull();
expect(harness.world.consumeDirtyState().deletedTroops).toEqual([1]);
});
});
+3
View File
@@ -106,6 +106,7 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
effects: GeneralActionEffect<TriggerState>[];
completed?: boolean;
deletedTroopIds?: number[];
alternative?: {
commandKey: string;
args: unknown;
@@ -146,6 +147,7 @@ export interface GeneralActionResolution {
commandKey: string;
args: unknown;
};
deletedTroopIds?: number[];
}
export const createGeneralPatchEffect = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
@@ -388,6 +390,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
logs,
effects: pendingEffects,
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
};
if (nextWorld.city) {
resolution.city = nextWorld.city as City;
@@ -7,7 +7,11 @@ import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect, createLogEffect } 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';
@@ -49,6 +53,15 @@ export class ActionDefinition<
}
buildConstraints(_ctx: ConstraintContext, _args: AbdicationArgs): Constraint[] {
if (_ctx.actorId === _args.destGeneralID) {
return [
{
name: 'differentDestGeneral',
requires: () => [],
test: () => ({ kind: 'deny', reason: '본인입니다' }),
},
];
}
return [beLord(), existsDestGeneral(), friendlyDestGeneral()];
}
@@ -66,7 +79,8 @@ export class ActionDefinition<
throw new Error('선양 대상 장수가 없습니다.');
}
const penaltyRaw = destGeneral.meta.penalty;
const penaltyRaw =
(destGeneral as General<TriggerState> & { penalty?: unknown }).penalty ?? destGeneral.meta.penalty;
if (penaltyRaw && typeof penaltyRaw === 'object' && !Array.isArray(penaltyRaw)) {
const penaltyMap = penaltyRaw as Record<string, unknown>;
for (const penaltyKey of blockedPenaltyKeys) {
@@ -79,6 +93,7 @@ export class ActionDefinition<
format: LogFormat.MONTH,
}),
],
completed: false,
};
}
}
@@ -135,7 +150,7 @@ export class ActionDefinition<
createGeneralPatchEffect(
{
officerLevel: 1,
experience: Math.floor(general.experience * 0.7),
experience: Math.round(general.experience * 0.7),
meta: {
...general.meta,
officer_city: 0,
@@ -143,7 +158,8 @@ export class ActionDefinition<
},
},
general.id
)
),
createNationPatchEffect({ chiefGeneralId: destGeneral.id }, nation.id)
);
return { effects };
@@ -48,8 +48,10 @@ export class ActionResolver<
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
nextMeta[key] = Math.round(value * 0.5);
}
nextMeta.specAge = 0;
nextMeta.specAge2 = 0;
delete nextMeta.specAge;
delete nextMeta.specAge2;
nextMeta.specage = 0;
nextMeta.specage2 = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
nextMeta[rankDataMetaKey(type)] = 0;
}
@@ -57,7 +59,7 @@ export class ActionResolver<
const josaYi = JosaUtil.pick(general.name, '이');
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
category: LogCategory.SUMMARY,
format: LogFormat.RAWTEXT,
});
context.addLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.', {
@@ -66,8 +66,8 @@ export class ActionResolver<
// Penalty
const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0;
const penaltyRatio = betrayal * 0.1;
const nextExp = Math.floor(general.experience * (1 - penaltyRatio));
const nextDed = Math.floor(general.dedication * (1 - penaltyRatio));
const nextExp = Math.round(general.experience * (1 - penaltyRatio));
const nextDed = Math.round(general.dedication * (1 - penaltyRatio));
context.addLog(`<D><b>${nation.name}</b></>에서 하야했습니다.`, {
category: LogCategory.ACTION,
@@ -80,7 +80,7 @@ export class ActionResolver<
const josaYi = JosaUtil.pick(general.name, '이');
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>에서 <R>하야</>했습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
category: LogCategory.SUMMARY,
format: LogFormat.RAWTEXT,
});
@@ -125,7 +125,10 @@ export class ActionResolver<
)
);
return { effects };
return {
effects,
...(general.troopId === general.id ? { deletedTroopIds: [general.id] } : {}),
};
}
}
@@ -223,6 +223,7 @@ describe('migrated general commands', () => {
expect(updatedHeir.officerLevel).toBe(12);
expect(updatedLord.officerLevel).toBe(1);
expect(updatedLord.experience).toBe(700);
expect(world.getNation(1)!.chiefGeneralId).toBe(heir.id);
});
it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => {
@@ -153,6 +153,9 @@ export const projectCoreDatabaseSnapshot = (rows: {
dex3: readNumber(meta, 'dex3'),
dex4: readNumber(meta, 'dex4'),
dex5: readNumber(meta, 'dex5'),
specAge: readNumber(meta, 'specage'),
specAge2: readNumber(meta, 'specage2'),
penalty: asRecord(row.penalty),
killTurn: readNumber(meta, 'killturn'),
mySet: readNumber(meta, 'myset'),
};
@@ -248,9 +248,12 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
atmos: readNumber(row, 'atmos'),
age: readNumber(row, 'age', 30),
npcState: readNumber(row, 'npcState'),
penalty: row.penalty,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
...meta,
specage: readNumber(row, 'specAge', readNumber(meta, 'specage')),
specage2: readNumber(row, 'specAge2', readNumber(meta, 'specage2')),
killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)),
leadership_exp: readNumber(row, 'leadershipExp', readNumber(meta, 'leadership_exp')),
strength_exp: readNumber(row, 'strengthExp', readNumber(meta, 'strength_exp')),
@@ -568,6 +571,9 @@ const projectWorld = (
dex3: toDatabaseInt(readNumber(general.meta, 'dex3')),
dex4: toDatabaseInt(readNumber(general.meta, 'dex4')),
dex5: toDatabaseInt(readNumber(general.meta, 'dex5')),
specAge: toDatabaseInt(readNumber(general.meta, 'specage')),
specAge2: toDatabaseInt(readNumber(general.meta, 'specage2')),
penalty: asRecord(general.penalty),
killTurn: readNumber(general.meta, 'killturn'),
mySet: readNumber(general.meta, 'myset'),
}));
@@ -2089,6 +2089,157 @@ integration('general command in-action failure matrix', () => {
);
});
type GeneralStatusTransitionBoundaryCase = {
name: string;
action: 'che_하야' | 'che_은퇴' | 'che_선양';
args?: Record<string, unknown>;
actorPatch?: Record<string, unknown>;
fixturePatches?: FixturePatches;
completed: boolean;
expectedActionKey?: string;
compareLogs?: boolean;
};
const generalStatusTransitionBoundaryCases: GeneralStatusTransitionBoundaryCase[] = [
{
name: 'resignation applies the betrayal penalty and returns excess resources',
action: 'che_하야',
actorPatch: {
officerLevel: 1,
experience: 1001,
dedication: 1003,
gold: 1500,
rice: 1600,
betray: 3,
},
completed: true,
compareLogs: true,
},
{
name: 'resigning troop leader releases every member',
action: 'che_하야',
actorPatch: { officerLevel: 1, troopId: 1 },
fixturePatches: {
generals: { 3: { troopId: 1 } },
troops: [{ id: 1, nationId: 1, name: '아국군' }],
},
completed: true,
},
{
name: 'retirement rejects age fifty nine',
action: 'che_은퇴',
actorPatch: { age: 59, lastTurn: { command: '은퇴', term: 1 } },
completed: false,
},
{
name: 'retirement rounds odd attributes and resets speciality ages and ranks',
action: 'che_은퇴',
actorPatch: {
age: 60,
leadership: 91,
strength: 81,
intelligence: 71,
experience: 1001,
dedication: 1003,
dex1: 101,
dex2: 103,
dex3: 105,
dex4: 107,
dex5: 109,
specAge: 65,
specAge2: 66,
lastTurn: { command: '은퇴', term: 1 },
},
fixturePatches: {
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
generalId: 1,
type,
value: index + 1,
})),
},
completed: true,
compareLogs: true,
},
{
name: 'abdication rejects a fractional target ID',
action: 'che_선양',
args: { destGeneralID: 3.5 },
completed: false,
},
{
name: 'abdication rejects the acting monarch as target',
action: 'che_선양',
args: { destGeneralID: 1 },
completed: false,
},
{
name: 'abdication rejects a target with no-chief penalty in action',
action: 'che_선양',
args: { destGeneralID: 3 },
fixturePatches: { generals: { 3: { penalty: { noChief: true } } } },
completed: false,
expectedActionKey: 'che_선양',
compareLogs: true,
},
{
name: 'abdication transfers the monarch office and experience penalty',
action: 'che_선양',
args: { destGeneralID: 3 },
actorPatch: { experience: 1001 },
completed: true,
compareLogs: true,
},
];
integration('general resignation, retirement, and abdication boundary, state, and log parity', () => {
it.each(generalStatusTransitionBoundaryCases)(
'$name',
async ({
name,
action,
args,
actorPatch,
fixturePatches,
completed,
expectedActionKey,
compareLogs,
}) => {
const request = buildRequest(action, args, actorPatch, fixturePatches);
request.setup!.world!.hiddenSeed = `general-status-transition-${name}`;
request.observe!.includeGlobalHistoryLogs = true;
request.observe!.includeNationHistoryLogs = true;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const actionKey = expectedActionKey ?? (completed ? action : '휴식');
const coreCompleted = actionKey === '휴식' ? true : completed;
expect(reference.execution.outcome).toMatchObject({ completed });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey,
usedFallback: !completed && actionKey === '휴식',
completed: coreCompleted,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
if (compareLogs) {
expect(semanticLogSignatures(core.after.logs)).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);
}
},
120_000
);
});
type SabotageProbabilityClampCase = {
name: string;
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';