diff --git a/packages/logic/src/actions/turn/nation/che_초토화.ts b/packages/logic/src/actions/turn/nation/che_초토화.ts index 6e23732..291a3da 100644 --- a/packages/logic/src/actions/turn/nation/che_초토화.ts +++ b/packages/logic/src/actions/turn/nation/che_초토화.ts @@ -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 { beChief, @@ -15,20 +15,22 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } 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 type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; import { z } from 'zod'; -import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destCityId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int().positive()), }); export type ScorchedEarthArgs = z.infer; @@ -37,6 +39,7 @@ export interface ScorchedEarthResolveContext< > extends GeneralActionResolveContext { destCity: City; destNation: Nation | null; + friendlyGenerals: Array>; } const ACTION_NAME = '초토화'; @@ -61,9 +64,9 @@ const calcReturnAmount = (destCity: City): number => { const calcReducedValue = (current: number, max: number, ratio: number): number => { 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 reducedSecu = calcReducedValue(destCity.security, destCity.securityMax, 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 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.meta = { + ...general.meta, + betray: Number(general.meta.betray ?? 0) + 1, + }; const effects: Array> = [ createCityPatchEffect( @@ -159,6 +166,7 @@ export class ActionDefinition< security: reducedSecu, defence: reducedDef, wall: reducedWall, + conflict: {}, meta: { ...destCity.meta, 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 }; } } @@ -229,10 +257,12 @@ export const actionContextBuilder: ActionContextBuilder = (ba return null; } const destNation = worldRef.getNationById(destCity.nationId); + const friendlyGenerals = worldRef.listGenerals().filter((general) => general.nationId === base.general.nationId); return { ...base, destCity, destNation: destNation ?? null, + friendlyGenerals, }; }; diff --git a/packages/logic/test/actions/turn/nationMissing.test.ts b/packages/logic/test/actions/turn/nationMissing.test.ts index 9a6751e..6089ca9 100644 --- a/packages/logic/test/actions/turn/nationMissing.test.ts +++ b/packages/logic/test/actions/turn/nationMissing.test.ts @@ -469,6 +469,7 @@ describe('Nation Missing Actions', () => { nation, destCity, destNation, + friendlyGenerals: [general], rng: {} as any, addLog: () => {}, } as any, diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index a5ad32e..867316d 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -116,6 +116,7 @@ export const projectCoreDatabaseSnapshot = (rows: { experience: row.experience, dedication: row.dedication, officerLevel: row.officerLevel, + betray: row.betray, personality: row.personality ?? null, specialDomestic: row.specialDomestic ?? null, specialWar: row.specialWar ?? null, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index cc869d8..22d1b6d 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -256,6 +256,7 @@ const buildGeneral = (row: Record, turnTime: Date): TurnGeneral dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')), dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')), explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')), + betray: readNumber(row, 'betray', readNumber(meta, 'betray')), officerCityId: readNumber(row, 'officerCityId', readNumber(meta, 'officerCityId')), block: readNumber(row, 'blockState', readNumber(meta, 'block')), }, @@ -500,6 +501,7 @@ const projectWorld = ( dedication: toDatabaseInt(general.dedication), expLevel: readNumber(general.meta, 'explevel'), officerLevel: general.officerLevel, + betray: readNumber(general.meta, 'betray'), personality: general.role.personality, specialDomestic: general.role.specialDomestic, specialWar: general.role.specialWar, diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 9ddfb21..7bc8313 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -4231,3 +4231,427 @@ integration('nation deception target, multistep, movement, RNG, and cooldown bou 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): 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 + ); + 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)[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 + ); +});