merge: match scorched-earth command boundaries

This commit is contained in:
2026-07-26 10:43:50 +00:00
5 changed files with 469 additions and 11 deletions
@@ -1,4 +1,4 @@
import type { City, GeneralTriggerState, Nation, TriggerValue } from '@sammo-ts/logic/domain/entities.js'; import type { City, General, GeneralTriggerState, Nation, TriggerValue } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
beChief, beChief,
@@ -15,20 +15,22 @@ import type {
GeneralActionOutcome, GeneralActionOutcome,
GeneralActionResolveContext, GeneralActionResolveContext,
} from '@sammo-ts/logic/actions/engine.js'; } from '@sammo-ts/logic/actions/engine.js';
import { createCityPatchEffect, createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import {
createCityPatchEffect,
createGeneralPatchEffect,
createLogEffect,
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod'; import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js'; import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
const ARGS_SCHEMA = z.object({ const ARGS_SCHEMA = z.object({
destCityId: z.preprocess( destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int().positive()),
(value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
}); });
export type ScorchedEarthArgs = z.infer<typeof ARGS_SCHEMA>; export type ScorchedEarthArgs = z.infer<typeof ARGS_SCHEMA>;
@@ -37,6 +39,7 @@ export interface ScorchedEarthResolveContext<
> extends GeneralActionResolveContext<TriggerState> { > extends GeneralActionResolveContext<TriggerState> {
destCity: City; destCity: City;
destNation: Nation | null; destNation: Nation | null;
friendlyGenerals: Array<General<TriggerState>>;
} }
const ACTION_NAME = '초토화'; const ACTION_NAME = '초토화';
@@ -61,9 +64,9 @@ const calcReturnAmount = (destCity: City): number => {
const calcReducedValue = (current: number, max: number, ratio: number): number => { const calcReducedValue = (current: number, max: number, ratio: number): number => {
if (max <= 0) { if (max <= 0) {
return Math.max(0, Math.floor(current * ratio)); return Math.max(0, Math.round(current * ratio));
} }
return Math.max(Math.floor(max * 0.1), Math.floor(current * ratio)); return Math.max(Math.round(max * 0.1), Math.round(current * ratio));
}; };
// 초토화 실행을 위한 정의/제약을 구성한다. // 초토화 실행을 위한 정의/제약을 구성한다.
@@ -141,12 +144,16 @@ export class ActionDefinition<
const reducedComm = calcReducedValue(destCity.commerce, destCity.commerceMax, 0.2); const reducedComm = calcReducedValue(destCity.commerce, destCity.commerceMax, 0.2);
const reducedSecu = calcReducedValue(destCity.security, destCity.securityMax, 0.2); const reducedSecu = calcReducedValue(destCity.security, destCity.securityMax, 0.2);
const reducedDef = calcReducedValue(destCity.defence, destCity.defenceMax, 0.2); const reducedDef = calcReducedValue(destCity.defence, destCity.defenceMax, 0.2);
const reducedWall = Math.max(Math.floor(destCity.wallMax * 0.1), Math.floor(destCity.wall * 0.5)); const reducedWall = Math.max(Math.round(destCity.wallMax * 0.1), Math.round(destCity.wall * 0.5));
const trust = typeof destCity.meta?.trust === 'number' ? Number(destCity.meta.trust) : 0; const trust = typeof destCity.meta?.trust === 'number' ? Number(destCity.meta.trust) : 0;
const expGain = 5 * (PRE_REQ_TURN + 1); const expGain = 5 * (PRE_REQ_TURN + 1);
general.experience = Math.max(0, Math.floor(general.experience * 0.9)) + expGain; general.experience = Math.max(0, Math.round(general.experience * 0.9 + expGain));
general.dedication += expGain; general.dedication += expGain;
general.meta = {
...general.meta,
betray: Number(general.meta.betray ?? 0) + 1,
};
const effects: Array<GeneralActionEffect<TriggerState>> = [ const effects: Array<GeneralActionEffect<TriggerState>> = [
createCityPatchEffect( createCityPatchEffect(
@@ -159,6 +166,7 @@ export class ActionDefinition<
security: reducedSecu, security: reducedSecu,
defence: reducedDef, defence: reducedDef,
wall: reducedWall, wall: reducedWall,
conflict: {},
meta: { meta: {
...destCity.meta, ...destCity.meta,
trust: Math.max(50, trust), trust: Math.max(50, trust),
@@ -210,6 +218,26 @@ export class ActionDefinition<
), ),
]; ];
for (const friendlyGeneral of context.friendlyGenerals) {
if (friendlyGeneral.id === general.id) {
continue;
}
effects.push(
createGeneralPatchEffect(
{
...(friendlyGeneral.officerLevel >= 5
? { experience: Math.round(friendlyGeneral.experience * 0.9) }
: {}),
meta: {
...friendlyGeneral.meta,
betray: Number(friendlyGeneral.meta.betray ?? 0) + 1,
},
},
friendlyGeneral.id
)
);
}
return { effects }; return { effects };
} }
} }
@@ -229,10 +257,12 @@ export const actionContextBuilder: ActionContextBuilder<ScorchedEarthArgs> = (ba
return null; return null;
} }
const destNation = worldRef.getNationById(destCity.nationId); const destNation = worldRef.getNationById(destCity.nationId);
const friendlyGenerals = worldRef.listGenerals().filter((general) => general.nationId === base.general.nationId);
return { return {
...base, ...base,
destCity, destCity,
destNation: destNation ?? null, destNation: destNation ?? null,
friendlyGenerals,
}; };
}; };
@@ -469,6 +469,7 @@ describe('Nation Missing Actions', () => {
nation, nation,
destCity, destCity,
destNation, destNation,
friendlyGenerals: [general],
rng: {} as any, rng: {} as any,
addLog: () => {}, addLog: () => {},
} as any, } as any,
@@ -116,6 +116,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
experience: row.experience, experience: row.experience,
dedication: row.dedication, dedication: row.dedication,
officerLevel: row.officerLevel, officerLevel: row.officerLevel,
betray: row.betray,
personality: row.personality ?? null, personality: row.personality ?? null,
specialDomestic: row.specialDomestic ?? null, specialDomestic: row.specialDomestic ?? null,
specialWar: row.specialWar ?? null, specialWar: row.specialWar ?? null,
@@ -256,6 +256,7 @@ const buildGeneral = (row: Record<string, unknown>, turnTime: Date): TurnGeneral
dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')), dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')),
dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')), dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')),
explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')), explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')),
betray: readNumber(row, 'betray', readNumber(meta, 'betray')),
officerCityId: readNumber(row, 'officerCityId', readNumber(meta, 'officerCityId')), officerCityId: readNumber(row, 'officerCityId', readNumber(meta, 'officerCityId')),
block: readNumber(row, 'blockState', readNumber(meta, 'block')), block: readNumber(row, 'blockState', readNumber(meta, 'block')),
}, },
@@ -500,6 +501,7 @@ const projectWorld = (
dedication: toDatabaseInt(general.dedication), dedication: toDatabaseInt(general.dedication),
expLevel: readNumber(general.meta, 'explevel'), expLevel: readNumber(general.meta, 'explevel'),
officerLevel: general.officerLevel, officerLevel: general.officerLevel,
betray: readNumber(general.meta, 'betray'),
personality: general.role.personality, personality: general.role.personality,
specialDomestic: general.role.specialDomestic, specialDomestic: general.role.specialDomestic,
specialWar: general.role.specialWar, specialWar: general.role.specialWar,
@@ -4231,3 +4231,427 @@ integration('nation deception target, multistep, movement, RNG, and cooldown bou
120_000 120_000
); );
}); });
type ScorchedEarthOutcome = 'fallback' | 'intermediate' | 'completed';
const scorchedEarthBoundaryCases: Array<{
name: string;
destCityId: unknown;
fixturePatches?: FixturePatches;
outcome: ScorchedEarthOutcome;
coreResolution?: boolean;
}> = [
{
name: 'rejects a missing destination city',
destCityId: 99,
outcome: 'fallback',
},
{
name: 'rejects a destination city occupied by another nation',
destCityId: 70,
fixturePatches: { cities: { 70: { nationId: 2 } } },
outcome: 'fallback',
},
{
name: 'rejects a neutral destination city',
destCityId: 70,
fixturePatches: { cities: { 70: { nationId: 0 } } },
outcome: 'fallback',
},
{
name: 'rejects a source city occupied by another nation',
destCityId: 70,
fixturePatches: { cities: { 3: { nationId: 2 } } },
outcome: 'fallback',
},
{
name: 'rejects an unsupplied source city',
destCityId: 70,
fixturePatches: { cities: { 3: { supplyState: 0 } } },
outcome: 'fallback',
},
{
name: 'rejects an unsupplied destination city',
destCityId: 70,
fixturePatches: { cities: { 70: { supplyState: 0 } } },
outcome: 'fallback',
},
{
name: 'rejects an actor below chief rank',
destCityId: 70,
fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } },
outcome: 'fallback',
coreResolution: false,
},
{
name: 'rejects the national capital',
destCityId: 70,
fixturePatches: { nations: { 1: { capitalCityId: 70 } } },
outcome: 'fallback',
},
{
name: 'rejects a remaining diplomacy restriction',
destCityId: 70,
fixturePatches: { nations: { 1: { diplomacyLimit: 1 } } },
outcome: 'fallback',
},
{
name: 'rejects any outgoing war relation',
destCityId: 70,
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
outcome: 'fallback',
},
{
name: 'rejects an outgoing self-war relation',
destCityId: 70,
fixturePatches: { diplomacy: { '1:1': { state: 0 } } },
outcome: 'fallback',
},
{
name: 'starts at term one without side effects',
destCityId: 70,
outcome: 'intermediate',
},
{
name: 'continues to term two without side effects',
destCityId: 70,
fixturePatches: {
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '초토화', arg: { destCityID: 70 }, term: 1 },
},
coreTurnLastByOfficerLevel: {
12: { command: '초토화', arg: { destCityId: 70 }, term: 1 },
},
},
},
},
outcome: 'intermediate',
},
{
name: 'restarts at term one after another command interrupted the stack',
destCityId: 70,
fixturePatches: {
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 2 },
},
},
},
},
outcome: 'intermediate',
},
{
name: 'accepts a numeric string destination city ID',
destCityId: '70',
outcome: 'completed',
},
{
name: 'truncates a fractional destination city ID',
destCityId: 70.9,
outcome: 'completed',
},
{
name: 'accepts supply state two for both source and destination',
destCityId: 70,
fixturePatches: {
cities: {
3: { supplyState: 2 },
70: { supplyState: 2 },
},
},
outcome: 'completed',
},
{
name: 'ignores a reverse-only war relation',
destCityId: 70,
fixturePatches: { diplomacy: { '2:1': { state: 0 } } },
outcome: 'completed',
},
{
name: 'uses ten-percent floors and raises low trust on a level-eight city',
destCityId: 70,
fixturePatches: {
cities: {
70: {
level: 8,
population: 1_000,
populationMax: 100_000,
agriculture: 10,
agricultureMax: 10_000,
commerce: 20,
commerceMax: 20_000,
security: 30,
securityMax: 30_000,
defence: 40,
defenceMax: 40_000,
wall: 50,
wallMax: 50_000,
trust: 20,
conflict: { 2: 30 },
},
},
},
outcome: 'completed',
},
{
name: 'applies high-value ratios, recovered resources, officer penalties, and city resets',
destCityId: 70,
fixturePatches: {
generals: {
1: { experience: 1_235 },
},
cities: {
23: { nationId: 1, frontState: 1, conflict: { 2: 10 } },
70: {
level: 7,
population: 80_003,
populationMax: 100_000,
agriculture: 8_003,
agricultureMax: 10_000,
commerce: 16_003,
commerceMax: 20_000,
security: 24_003,
securityMax: 30_000,
defence: 32_003,
defenceMax: 40_000,
wall: 40_001,
wallMax: 50_000,
trust: 80,
conflict: { 2: 30 },
},
},
},
outcome: 'completed',
},
];
const calcScorchedEarthReturnAmount = (city: Record<string, unknown>): number => {
let amount = readNumericField(city, 'population') / 5;
for (const [currentKey, maxKey] of [
['agriculture', 'agricultureMax'],
['commerce', 'commerceMax'],
['security', 'securityMax'],
] as const) {
const current = readNumericField(city, currentKey);
const max = readNumericField(city, maxKey);
amount *= (current - max * 0.5) / max + 0.8;
}
return Math.trunc(amount);
};
integration('nation scorched-earth constraints, multistep, city values, officers, and diplomacy boundaries', () => {
it.each(scorchedEarthBoundaryCases)(
'$name matches legacy progress, destruction, resources, officers, logs, RNG, and semantic delta',
async ({ destCityId, fixturePatches, outcome, coreResolution = true }) => {
const normalizedDestCityId = Math.trunc(Number(destCityId));
const completed = outcome === 'completed';
const fallback = outcome === 'fallback';
const completionNationPatch = completed
? {
turnLastByOfficerLevel: {
12: { command: '초토화', arg: { destCityID: destCityId }, term: 2 },
},
coreTurnLastByOfficerLevel: {
12: { command: '초토화', arg: { destCityId: normalizedDestCityId }, term: 2 },
},
}
: {};
const request = buildRequest(
'che_초토화',
{ destCityID: destCityId },
{
...fixturePatches,
nations: {
...fixturePatches?.nations,
1: {
...fixturePatches?.nations?.[1],
...completionNationPatch,
},
},
cities: {
...fixturePatches?.cities,
70: {
nationId: 1,
...fixturePatches?.cities?.[70],
},
},
generals: {
3: { betray: 1, ...fixturePatches?.generals?.[3] },
4: {
nationId: 1,
cityId: 3,
officerLevel: 5,
officerCityId: 3,
experience: 1_235,
betray: 2,
...fixturePatches?.generals?.[4],
},
5: {
nationId: 1,
cityId: 3,
officerLevel: 4,
officerCityId: 0,
experience: 1_235,
betray: 3,
...fixturePatches?.generals?.[5],
},
6: {
nationId: 2,
cityId: 70,
officerLevel: 5,
officerCityId: 70,
experience: 1_235,
betray: 4,
...fixturePatches?.generals?.[6],
},
...fixturePatches?.generals,
},
}
);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed });
if (coreResolution) {
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_초토화',
actionKey: fallback ? '휴식' : 'che_초토화',
usedFallback: fallback,
...(fallback ? {} : { completed }),
});
} else {
expect(core.execution.outcome).toBeUndefined();
}
if (outcome === 'intermediate') {
const expectedTerm =
fixturePatches?.nations?.[1]?.turnLastByOfficerLevel &&
(fixturePatches.nations[1].turnLastByOfficerLevel as Record<number, { command?: string }>)[12]
?.command === '초토화'
? 2
: 1;
expect(reference.execution.outcome).toMatchObject({
lastTurn: {
command: '초토화',
term: expectedTerm,
},
});
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
command: '초토화',
term: expectedTerm,
});
}
for (const snapshot of [reference, core]) {
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
const cityBefore = snapshot.before.cities.find((entry) => entry.id === normalizedDestCityId);
const cityAfter = snapshot.after.cities.find((entry) => entry.id === normalizedDestCityId);
const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
if (!completed) {
expect(readNationResource(nationAfter, 'gold')).toBe(readNationResource(nationBefore, 'gold'));
expect(readNationResource(nationAfter, 'rice')).toBe(readNationResource(nationBefore, 'rice'));
expect(readNumericField(actorAfter, 'experience')).toBe(
readNumericField(actorBefore, 'experience')
);
expect(readNumericField(actorAfter, 'dedication')).toBe(
readNumericField(actorBefore, 'dedication')
);
continue;
}
const rewardAmount = calcScorchedEarthReturnAmount(cityBefore!);
expect(readNationResource(nationAfter, 'gold') - readNationResource(nationBefore, 'gold')).toBe(
rewardAmount
);
expect(readNationResource(nationAfter, 'rice') - readNationResource(nationBefore, 'rice')).toBe(
rewardAmount
);
expect(
readNumericField(nationAfter, 'diplomacyLimit') - readNumericField(nationBefore, 'diplomacyLimit')
).toBe(24);
expect(readNumericField(actorAfter, 'experience')).toBe(
Math.round(readNumericField(actorBefore, 'experience') * 0.9 + 15)
);
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
15
);
expect(readNumericField(actorAfter, 'betray') - readNumericField(actorBefore, 'betray')).toBe(1);
for (const generalId of [3, 4, 5, 6]) {
const before = snapshot.before.generals.find((entry) => entry.id === generalId);
const after = snapshot.after.generals.find((entry) => entry.id === generalId);
const friendly = [3, 4, 5].includes(generalId);
const officer = readNumericField(before, 'officerLevel') >= 5;
expect(readNumericField(after, 'experience')).toBe(
friendly && officer
? Math.round(readNumericField(before, 'experience') * 0.9)
: readNumericField(before, 'experience')
);
expect(readNumericField(after, 'betray') - readNumericField(before, 'betray')).toBe(
friendly ? 1 : 0
);
}
expect(cityAfter).toMatchObject({
nationId: 0,
frontState: 0,
trust: Math.max(50, readNumericField(cityBefore, 'trust')),
population: Math.max(
Math.round(readNumericField(cityBefore, 'populationMax') * 0.1),
Math.round(readNumericField(cityBefore, 'population') * 0.2)
),
agriculture: Math.max(
Math.round(readNumericField(cityBefore, 'agricultureMax') * 0.1),
Math.round(readNumericField(cityBefore, 'agriculture') * 0.2)
),
commerce: Math.max(
Math.round(readNumericField(cityBefore, 'commerceMax') * 0.1),
Math.round(readNumericField(cityBefore, 'commerce') * 0.2)
),
security: Math.max(
Math.round(readNumericField(cityBefore, 'securityMax') * 0.1),
Math.round(readNumericField(cityBefore, 'security') * 0.2)
),
defence: Math.max(
Math.round(readNumericField(cityBefore, 'defenceMax') * 0.1),
Math.round(readNumericField(cityBefore, 'defence') * 0.2)
),
wall: Math.max(
Math.round(readNumericField(cityBefore, 'wallMax') * 0.1),
Math.round(readNumericField(cityBefore, 'wall') * 0.5)
),
});
expect(Object.keys((cityAfter?.conflict ?? {}) as object)).toHaveLength(0);
const auxBefore = readNationMeta(nationBefore);
const auxAfter = readNationMeta(nationAfter);
expect(
readNumericField(auxAfter, 'did_특성초토화') - readNumericField(auxBefore, 'did_특성초토화')
).toBe(readNumericField(cityBefore, 'level') >= 8 ? 1 : 0);
expect(snapshot.after.logs.slice(snapshot.before.logs.length).map((entry) => entry.text)).toEqual(
expect.arrayContaining([
expect.stringContaining('초토화했습니다'),
expect.stringContaining('초토화</> 명령'),
])
);
}
expect(reference.rng).toEqual([]);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});