fix general status transition parity
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_탈취';
|
||||
|
||||
Reference in New Issue
Block a user