diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index f9de9b0..d07a57a 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -706,6 +706,7 @@ export const createReservedTurnHandler = async (options: { requestedAction: string; actionKey: string; usedFallback: boolean; + completed?: boolean; blockedReason?: string; aiState?: ReturnType; }) => void; @@ -827,7 +828,13 @@ export const createReservedTurnHandler = async (options: { applyNextTurnAt: boolean, alternativeDepth = 0, sharedActionRng?: RandUtil - ): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => { + ): { + nextTurnAt?: Date; + actionKey: string; + usedFallback: boolean; + completed: boolean; + blockedReason?: string; + } => { const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition); const rawArgs = extractArgsRecord(command.args); const parsedArgs = resolvedDefinition.parseArgs(rawArgs); @@ -1028,7 +1035,7 @@ export const createReservedTurnHandler = async (options: { executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? `${definition.name} 수행중... (${nextTerm}/${termMax})`; logs.push(createActionLog(progressText)); - return { actionKey, usedFallback, blockedReason }; + return { actionKey, usedFallback, completed: false, blockedReason }; } } @@ -1047,7 +1054,7 @@ export const createReservedTurnHandler = async (options: { currentGeneral = resolution.general as TurnGeneral; currentCity = resolution.city ?? currentCity; currentNation = resolution.nation ?? currentNation; - if (!resolution.alternative && !usedFallback) { + if (!resolution.alternative && !usedFallback && resolution.completed) { currentGeneral = applyLegacyGeneralProgression( currentGeneral, generalBeforeExecution, @@ -1059,6 +1066,7 @@ export const createReservedTurnHandler = async (options: { !resolution.alternative && kind === 'nation' && !usedFallback && + resolution.completed && definition.countsAsInheritanceActiveAction ) { const meta = { ...currentGeneral.meta }; @@ -1070,6 +1078,7 @@ export const createReservedTurnHandler = async (options: { !resolution.alternative && kind === 'general' && !usedFallback && + resolution.completed && executionDefinition.getInheritanceActiveActionAmount ) { const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs); @@ -1086,7 +1095,7 @@ export const createReservedTurnHandler = async (options: { (resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ?? currentNation; } - if (!resolution.alternative && kind === 'general' && !usedFallback) { + if (!resolution.alternative && kind === 'general' && !usedFallback && resolution.completed) { const actionChangedLastTurn = JSON.stringify(currentGeneral.lastTurn ?? {}) !== lastTurnBeforeExecution; const nextMeta = { ...currentGeneral.meta }; @@ -1107,7 +1116,13 @@ export const createReservedTurnHandler = async (options: { }, }; } - if (!resolution.alternative && kind === 'nation' && !usedFallback && currentNation) { + if ( + !resolution.alternative && + kind === 'nation' && + !usedFallback && + resolution.completed && + currentNation + ) { const metaKey = nationLastTurnKey(currentGeneral.officerLevel); const nextMeta: Record = { ...currentNation.meta, @@ -1267,6 +1282,7 @@ export const createReservedTurnHandler = async (options: { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, usedFallback, + completed: resolution.completed, blockedReason, }; }; @@ -1418,6 +1434,7 @@ export const createReservedTurnHandler = async (options: { requestedAction: nationCommand.action, actionKey: nationResult.actionKey, usedFallback: nationResult.usedFallback, + completed: nationResult.completed, ...(nationResult.blockedReason ? { blockedReason: nationResult.blockedReason } : {}), ...(nationAiState ? { aiState: nationAiState } : {}), }); @@ -1464,6 +1481,7 @@ export const createReservedTurnHandler = async (options: { ? { actionKey: DEFAULT_ACTION, usedFallback: true, + completed: false, blockedReason: '블럭 대상자입니다.', } : runAction('general', generalDefinitions, generalFallback, generalCommand, true); @@ -1474,6 +1492,7 @@ export const createReservedTurnHandler = async (options: { requestedAction: generalCommand.action, actionKey: generalResult.actionKey, usedFallback: generalResult.usedFallback, + completed: generalResult.completed, ...(generalResult.blockedReason ? { blockedReason: generalResult.blockedReason } : {}), ...(generalAiState ? { aiState: generalAiState } : {}), }); diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 185b726..6d839c5 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -105,6 +105,7 @@ export type GeneralActionEffect { effects: GeneralActionEffect[]; + completed?: boolean; alternative?: { commandKey: string; args: unknown; @@ -120,6 +121,7 @@ export interface GeneralActionResolution { general: General; city?: City; nation?: Nation | null; + completed: boolean; nextTurnAt: Date; logs: LogEntryDraft[]; effects: GeneralActionEffect[]; @@ -381,6 +383,7 @@ export const resolveGeneralAction = extends GeneralActionResolveContext { neutralCandidateCities: City[]; nationGenerals: General[]; + oldCapitalCity?: City; } const ACTION_NAME = '무작위 수도 이전'; @@ -81,13 +82,14 @@ export class ActionDefinition< context: RandomMoveCapitalResolveContext, _args: RandomMoveCapitalArgs ): GeneralActionOutcome { - const { general, nation, neutralCandidateCities, nationGenerals, rng } = context; + const { general, nation, neutralCandidateCities, nationGenerals, oldCapitalCity, rng } = context; if (!nation) { return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; } if (neutralCandidateCities.length === 0) { return { + completed: false, effects: [ createLogEffect(`이동할 수 있는 도시가 없습니다.`, { scope: LogScope.GENERAL, @@ -124,6 +126,7 @@ export class ActionDefinition< createCityPatchEffect( { nationId: nation.id, + conflict: {}, }, destCity.id ), @@ -132,6 +135,11 @@ export class ActionDefinition< { nationId: 0, frontState: 0, + conflict: {}, + meta: { + ...oldCapitalCity?.meta, + officer_set: 0, + }, }, oldCityId! ), @@ -213,11 +221,13 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { const allCities = worldRef.listCities(); const neutralCandidateCities = allCities.filter((c) => c.nationId === 0 && c.level >= 5 && c.level <= 6); const nationGenerals = worldRef.listGenerals().filter((g) => g.nationId === base.general.nationId); + const oldCapitalCity = base.nation?.capitalCityId ? worldRef.getCityById(base.nation.capitalCityId) : undefined; return { ...base, neutralCandidateCities, nationGenerals, + ...(oldCapitalCity ? { oldCapitalCity } : {}), }; }; diff --git a/packages/logic/src/constraints/misc.ts b/packages/logic/src/constraints/misc.ts index 6db0fe2..f65b910 100644 --- a/packages/logic/src/constraints/misc.ts +++ b/packages/logic/src/constraints/misc.ts @@ -39,7 +39,7 @@ export const beOpeningPart = (): Constraint => ({ return { kind: 'deny', reason: '초반 제한 중에는 불가능합니다.' }; } - if (relYear + 1 <= openingPartYear) { + if (relYear + 1 < openingPartYear) { return allow(); } @@ -47,12 +47,7 @@ export const beOpeningPart = (): Constraint => ({ }, }); -export const reqEnvValue = ( - key: string, - comp: CompareOperator, - reqVal: unknown, - failMessage: string -): Constraint => ({ +export const reqEnvValue = (key: string, comp: CompareOperator, reqVal: unknown, failMessage: string): Constraint => ({ name: 'reqEnvValue', requires: () => [{ kind: 'env', key }], test: (_ctx, view) => { diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index b130574..a5ad32e 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -169,10 +169,12 @@ export const projectCoreDatabaseSnapshot = (rows: { defenceMax: row.defenceMax, wall: row.wall, wallMax: row.wallMax, + conflict: asRecord(row.conflict), state: readNumber(meta, 'state'), term: readNumber(meta, 'term'), trust: row.trust, trade: row.trade, + officerSet: readNumber(meta, 'officer_set'), }; }); const nations = rows.nations.map((row) => { diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index fb9c416..7b8e97d 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -18,10 +18,7 @@ import type { TurnWorldSnapshot, TurnWorldState, } from '@sammo-ts/game-engine/turn/types.js'; -import { - applyPersistedRankRowsToMeta, - buildLegacyComparableRankRows, -} from '@sammo-ts/game-engine/turn/rankData.js'; +import { applyPersistedRankRowsToMeta, buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js'; import { canonicalizeTurnCommandArgs, @@ -121,6 +118,10 @@ class TracingRng implements RNG { const asRecord = (value: unknown): Record => typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; +const asNumberRecord = (value: unknown): Record => + Object.fromEntries( + Object.entries(asRecord(value)).filter((entry): entry is [string, number] => typeof entry[1] === 'number') + ); const readNumber = (record: Record, key: string, fallback = 0): number => { const value = record[key]; @@ -392,12 +393,13 @@ const buildWorldInput = ( id: definition.id, name: readString(row, 'name', definition.name), nationId: readNumber(row, 'nationId'), - level: - randomFoundingCandidateCityIds === null - ? readNumber(row, 'level', definition.level) - : randomFoundingCandidateCityIds.has(definition.id) - ? 5 - : 4, + level: observedCityRows.has(definition.id) + ? readNumber(row, 'level', definition.level) + : randomFoundingCandidateCityIds === null + ? definition.level + : randomFoundingCandidateCityIds.has(definition.id) + ? 5 + : 4, state: readNumber(row, 'state'), population: readNumber(row, 'population', definition.initial.population), populationMax: readNumber(row, 'populationMax', definition.max.population), @@ -413,10 +415,12 @@ const buildWorldInput = ( defenceMax: readNumber(row, 'defenceMax', definition.max.defence), wall: readNumber(row, 'wall', definition.initial.wall), wallMax: readNumber(row, 'wallMax', definition.max.wall), + conflict: asNumberRecord(row.conflict), meta: { trust: readNumber(row, 'trust', map.defaults?.trust ?? 50), trade: readNumber(row, 'trade', map.defaults?.trade ?? 100), term: readNumber(row, 'term'), + officer_set: readNumber(row, 'officerSet'), }, }; }), @@ -567,10 +571,12 @@ const projectWorld = ( defenceMax: city.defenceMax, wall: toDatabaseInt(city.wall), wallMax: city.wallMax, + conflict: city.conflict ?? {}, state: city.state, term: readNumber(city.meta, 'term'), trust: readNumber(city.meta, 'trust'), trade: readNumber(city.meta, 'trade'), + officerSet: readNumber(city.meta, 'officer_set'), })), nations: nations .filter((nation) => selector.nationIds.has(nation.id)) diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 944d1ec..d5ec670 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -22,6 +22,7 @@ const readNationMeta = (row: { meta?: unknown } | undefined): Record) : {}; +const readGeneralMeta = readNationMeta; const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...'; const ignoredLifecyclePaths = [ @@ -252,6 +253,7 @@ const buildRequest = ( ...Object.keys(fixturePatches.cities ?? {}) .map(Number) .filter((id) => id !== 3 && id !== 70), + ...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70), ], nationIds: [1, 2], logAfterId: 0, @@ -2130,3 +2132,272 @@ integration('nation reduce city level, value, recovery, and city constraints', ( ).toEqual([]); }, 120_000); }); + +type RandomCapitalOutcome = 'fallback' | 'incomplete' | 'completed'; + +const randomCapitalCases: Array<{ + name: string; + fixturePatches?: FixturePatches; + candidateCityIds: number[]; + flag?: number | null; + officerLevel?: number; + outcome: RandomCapitalOutcome; +}> = [ + { + name: 'rejects a source city occupied by another nation', + fixturePatches: { cities: { 3: { nationId: 2 } } }, + candidateCityIds: [70], + outcome: 'fallback', + }, + { + name: 'rejects an unsupplied source city', + fixturePatches: { cities: { 3: { supplyState: 0 } } }, + candidateCityIds: [70], + outcome: 'fallback', + }, + { + name: 'rejects a non-lord actor', + candidateCityIds: [70], + officerLevel: 11, + outcome: 'fallback', + }, + { + name: 'rejects at the opening-part year boundary', + fixturePatches: { world: { year: 182 } }, + candidateCityIds: [70], + outcome: 'fallback', + }, + { + name: 'rejects a missing remaining-use flag', + candidateCityIds: [70], + flag: null, + outcome: 'fallback', + }, + { + name: 'rejects a zero remaining-use flag', + candidateCityIds: [70], + flag: 0, + outcome: 'fallback', + }, + { + name: 'stays incomplete when no eligible city exists', + candidateCityIds: [], + outcome: 'incomplete', + }, + { + name: 'stays incomplete when the only neutral city is level four', + fixturePatches: { cities: { 70: { level: 4 } } }, + candidateCityIds: [70], + outcome: 'incomplete', + }, + { + name: 'stays incomplete when the only neutral city is level seven', + fixturePatches: { cities: { 70: { level: 7 } } }, + candidateCityIds: [70], + outcome: 'incomplete', + }, + { + name: 'allows a single level-five neutral city', + fixturePatches: { cities: { 70: { level: 5 } } }, + candidateCityIds: [70], + outcome: 'completed', + }, + { + name: 'allows a single level-six neutral city', + fixturePatches: { cities: { 70: { level: 6 } } }, + candidateCityIds: [70], + outcome: 'completed', + }, + { + name: 'matches legacy ordering and choice across two eligible cities', + fixturePatches: { + cities: { + 70: { level: 5 }, + 71: { + nationId: 0, + level: 6, + conflict: { 2: 30 }, + officerSet: 6, + }, + }, + }, + candidateCityIds: [71, 70], + outcome: 'completed', + }, +]; + +integration('nation random capital constraints, candidates, RNG, and city reset effects', () => { + it.each(randomCapitalCases)( + '$name matches legacy completion, destination, city resets, RNG, and semantic delta', + async ({ fixturePatches, candidateCityIds, flag = 1, officerLevel = 12, outcome }) => { + const request = buildRequest('che_무작위수도이전', undefined, { + ...fixturePatches, + world: { + year: 181, + ...fixturePatches?.world, + }, + generals: { + ...fixturePatches?.generals, + 1: { + ...fixturePatches?.generals?.[1], + officerLevel, + }, + }, + nations: { + ...fixturePatches?.nations, + 1: { + ...fixturePatches?.nations?.[1], + meta: { + ...(flag === null ? {} : { can_무작위수도이전: flag }), + }, + turnLastByOfficerLevel: { + [officerLevel]: { + command: '무작위 수도 이전', + arg: {}, + term: 1, + }, + }, + }, + }, + cities: { + ...fixturePatches?.cities, + 3: { + conflict: { 2: 10 }, + officerSet: 7, + ...fixturePatches?.cities?.[3], + }, + 70: { + nationId: 0, + level: candidateCityIds.includes(70) ? 5 : 4, + conflict: { 2: 20 }, + officerSet: 5, + ...fixturePatches?.cities?.[70], + }, + }, + randomFoundingCandidateCityIds: candidateCityIds, + }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const referenceNationBefore = reference.before.nations.find((entry) => entry.id === 1); + const referenceNationAfter = reference.after.nations.find((entry) => entry.id === 1); + const coreNationBefore = core.before.nations.find((entry) => entry.id === 1); + const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); + const referenceGeneralBefore = reference.before.generals.find((entry) => entry.id === 1); + const referenceGeneralAfter = reference.after.generals.find((entry) => entry.id === 1); + const coreGeneralBefore = core.before.generals.find((entry) => entry.id === 1); + const coreGeneralAfter = core.after.generals.find((entry) => entry.id === 1); + const completed = outcome === 'completed'; + const incomplete = outcome === 'incomplete'; + + expect(reference.execution.outcome).toMatchObject({ + // The ref runner infers completion from the previous term=1. + // A no-candidate run returns false without resetting that term, + // so its heuristic reports a false positive for incomplete cases. + completed: outcome !== 'fallback', + }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_무작위수도이전', + actionKey: outcome === 'fallback' ? '휴식' : 'che_무작위수도이전', + usedFallback: outcome === 'fallback', + ...(outcome === 'fallback' ? {} : { completed }), + }); + expect(readNumericField(referenceNationAfter, 'capitalCityId')).toBe( + completed ? readNumericField(coreNationAfter, 'capitalCityId') : 3 + ); + expect(readNumericField(coreNationAfter, 'capitalCityId')).toBe( + completed ? readNumericField(referenceNationAfter, 'capitalCityId') : 3 + ); + for (const [before, after] of [ + [referenceGeneralBefore, referenceGeneralAfter], + [coreGeneralBefore, coreGeneralAfter], + ] as const) { + expect(readNumericField(after, 'experience') - readNumericField(before, 'experience')).toBe( + completed ? 10 : 0 + ); + expect(readNumericField(after, 'dedication') - readNumericField(before, 'dedication')).toBe( + completed ? 10 : 0 + ); + } + + if (completed) { + const destCityId = readNumericField(referenceNationAfter, 'capitalCityId'); + expect(candidateCityIds).toContain(destCityId); + for (const [beforeSnapshot, afterSnapshot] of [ + [reference.before, reference.after], + [core.before, core.after], + ] as const) { + const oldCity = afterSnapshot.cities.find((entry) => entry.id === 3); + const destCity = afterSnapshot.cities.find((entry) => entry.id === destCityId); + const actor = afterSnapshot.generals.find((entry) => entry.id === 1); + const compatriot = afterSnapshot.generals.find((entry) => entry.id === 3); + const foreignGeneralBefore = beforeSnapshot.generals.find((entry) => entry.id === 2); + const foreignGeneralAfter = afterSnapshot.generals.find((entry) => entry.id === 2); + + expect(oldCity).toMatchObject({ + nationId: 0, + frontState: 0, + conflict: {}, + officerSet: 0, + }); + expect(destCity).toMatchObject({ + nationId: 1, + conflict: {}, + }); + expect(readNumericField(actor, 'cityId')).toBe(destCityId); + expect(readNumericField(compatriot, 'cityId')).toBe(destCityId); + expect(readNumericField(foreignGeneralAfter, 'cityId')).toBe( + readNumericField(foreignGeneralBefore, 'cityId') + ); + } + expect(readNumericField(readNationMeta(referenceNationAfter), 'can_무작위수도이전')).toBe(0); + expect(readNumericField(readNationMeta(coreNationAfter), 'can_무작위수도이전')).toBe(0); + expect(reference.rng).toHaveLength(1); + expect(reference.rng[0]).toMatchObject({ + operation: 'nextInt', + arguments: { maxInclusive: candidateCityIds.length - 1 }, + }); + } else { + expect(reference.rng).toEqual([]); + expect(readNationMeta(referenceNationAfter).can_무작위수도이전).toBe( + readNationMeta(referenceNationBefore).can_무작위수도이전 + ); + expect(readNationMeta(coreNationAfter).can_무작위수도이전).toBe( + readNationMeta(coreNationBefore).can_무작위수도이전 + ); + } + if (incomplete) { + expect(reference.execution.outcome).toMatchObject({ + completed: true, + lastTurn: { + command: '무작위 수도 이전', + term: 1, + }, + }); + expect(readNationMeta(coreNationAfter).turn_last_12).toMatchObject({ + command: '무작위 수도 이전', + term: 1, + }); + expect(readNumericField(readGeneralMeta(coreGeneralAfter), 'inherit_active_action')).toBe( + readNumericField(readGeneralMeta(coreGeneralBefore), 'inherit_active_action') + ); + const noCandidateText = '이동할 수 있는 도시가 없습니다.'; + expect(reference.after.logs.slice(reference.before.logs.length).map((entry) => entry.text)).toEqual( + expect.arrayContaining([expect.stringContaining(noCandidateText)]) + ); + expect(core.after.logs.map((entry) => entry.text)).toEqual( + expect.arrayContaining([expect.stringContaining(noCandidateText)]) + ); + } + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +});