diff --git a/packages/logic/src/actions/turn/general/che_내정특기초기화.ts b/packages/logic/src/actions/turn/general/che_내정특기초기화.ts index 34f68e3..3b9db75 100644 --- a/packages/logic/src/actions/turn/general/che_내정특기초기화.ts +++ b/packages/logic/src/actions/turn/general/che_내정특기초기화.ts @@ -5,6 +5,7 @@ import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { setMetaNumber } from '@sammo-ts/logic/war/utils.js'; import { DOMESTIC_TRAIT_KEYS } from '@sammo-ts/logic/triggers/special/domestic/index.js'; @@ -86,6 +87,7 @@ export class ActionDefinition< delete general.meta.specAge; setMetaNumber(general.meta, 'specage', general.age + 1); context.addLog('새로운 내정 특기를 가질 준비가 되었습니다.'); + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); return { effects: [] }; } } diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 8984b87..92b27e8 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; +import { asRecord, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; @@ -3258,6 +3258,154 @@ integration('general command post-required cooldown boundary matrix', () => { }, 120_000); }); +type GeneralSpecialResetBoundaryCase = { + name: string; + action: 'che_내정특기초기화' | 'che_전투특기초기화'; + actorPatch: Record; + completed: boolean; + previousTypeKey: 'prev_types_special' | 'prev_types_special2'; + expectedPreviousTypes?: string[]; + expectedSpecAgeKey: 'specAge' | 'specAge2'; + expectUniqueItem?: boolean; +}; + +const generalSpecialResetBoundaryCases: GeneralSpecialResetBoundaryCase[] = [ + { + name: 'domestic reset rejects an actor without a domestic trait', + action: 'che_내정특기초기화', + actorPatch: { specialDomestic: 'None' }, + completed: false, + previousTypeKey: 'prev_types_special', + expectedSpecAgeKey: 'specAge', + }, + { + name: 'war reset rejects an actor without a war trait', + action: 'che_전투특기초기화', + actorPatch: { specialWar: 'None' }, + completed: false, + previousTypeKey: 'prev_types_special2', + expectedSpecAgeKey: 'specAge2', + }, + { + name: 'domestic reset restarts the previous-type cycle after every trait was seen', + action: 'che_내정특기초기화', + actorPatch: { + specialDomestic: 'che_인덕', + lastTurn: { command: '내정 특기 초기화', term: 1 }, + meta: { prev_types_special: Array.from({ length: 7 }, (_, index) => `domestic-${index}`) }, + }, + completed: true, + previousTypeKey: 'prev_types_special', + expectedPreviousTypes: ['che_인덕'], + expectedSpecAgeKey: 'specAge', + }, + { + name: 'war reset restarts the previous-type cycle after every trait was seen', + action: 'che_전투특기초기화', + actorPatch: { + specialWar: 'che_귀병', + lastTurn: { command: '전투 특기 초기화', term: 1 }, + meta: { prev_types_special2: Array.from({ length: 19 }, (_, index) => `war-${index}`) }, + }, + completed: true, + previousTypeKey: 'prev_types_special2', + expectedPreviousTypes: ['che_귀병'], + expectedSpecAgeKey: 'specAge2', + }, + { + name: 'domestic reset stores the old trait and runs the forced unique lottery', + action: 'che_내정특기초기화', + actorPatch: { + specialDomestic: 'che_인덕', + lastTurn: { command: '내정 특기 초기화', term: 1 }, + meta: { inheritRandomUnique: true, prev_types_special: ['che_농업'] }, + }, + completed: true, + previousTypeKey: 'prev_types_special', + expectedPreviousTypes: ['che_농업', 'che_인덕'], + expectedSpecAgeKey: 'specAge', + expectUniqueItem: true, + }, + { + name: 'war reset stores the old trait and runs the forced unique lottery', + action: 'che_전투특기초기화', + actorPatch: { + specialWar: 'che_귀병', + lastTurn: { command: '전투 특기 초기화', term: 1 }, + meta: { inheritRandomUnique: true, prev_types_special2: ['che_신산'] }, + }, + completed: true, + previousTypeKey: 'prev_types_special2', + expectedPreviousTypes: ['che_신산', 'che_귀병'], + expectedSpecAgeKey: 'specAge2', + expectUniqueItem: true, + }, +]; + +integration('general special reset constraint, state, unique lottery, and log parity', () => { + it.each(generalSpecialResetBoundaryCases)( + '$name', + async ({ + name, + action, + actorPatch, + completed, + previousTypeKey, + expectedPreviousTypes, + expectedSpecAgeKey, + expectUniqueItem, + }) => { + const request = buildRequest(action, undefined, actorPatch, { + world: { initYear: 180, initMonth: 1 }, + }); + request.setup!.world!.hiddenSeed = `general-special-reset-${name}`; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const referenceActor = reference.after.generals.find((entry) => entry.id === 1); + const coreActor = core.after.generals.find((entry) => entry.id === 1); + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: completed ? action : '휴식', + usedFallback: !completed, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + + if (completed) { + expect(asRecord(referenceActor?.meta)[previousTypeKey]).toEqual(expectedPreviousTypes); + expect(asRecord(coreActor?.meta)[previousTypeKey]).toEqual(expectedPreviousTypes); + expect(referenceActor?.[expectedSpecAgeKey]).toBe(31); + expect(coreActor?.[expectedSpecAgeKey]).toBe(31); + } + if (expectUniqueItem) { + const referenceItems = [ + referenceActor?.itemHorse, + referenceActor?.itemWeapon, + referenceActor?.itemBook, + referenceActor?.itemExtra, + ]; + const coreItems = [coreActor?.itemHorse, coreActor?.itemWeapon, coreActor?.itemBook, coreActor?.itemExtra]; + expect(referenceItems.some((item) => item && item !== 'None')).toBe(true); + expect(coreItems).toEqual(referenceItems); + } + }, + 120_000 + ); +}); + const missingTargetCases: Array<{ name: string; action: string; args: Record }> = [ { name: 'gift to a missing general',