diff --git a/packages/logic/src/actions/turn/general/che_강행.ts b/packages/logic/src/actions/turn/general/che_강행.ts index 1718890..623f7b5 100644 --- a/packages/logic/src/actions/turn/general/che_강행.ts +++ b/packages/logic/src/actions/turn/general/che_강행.ts @@ -17,7 +17,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js' import type { GeneralTurnCommandSpec } from './index.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; -import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; export interface ForcedMoveResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -30,7 +30,7 @@ export interface ForcedMoveResolveContext< const ACTION_NAME = '강행'; const ACTION_KEY = 'che_강행'; const ARGS_SCHEMA = z.object({ - destCityId: z.number(), + destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int()), }); export type ForcedMoveArgs = z.infer; diff --git a/packages/logic/src/actions/turn/general/che_귀환.ts b/packages/logic/src/actions/turn/general/che_귀환.ts index 41f667e..dfadc3c 100644 --- a/packages/logic/src/actions/turn/general/che_귀환.ts +++ b/packages/logic/src/actions/turn/general/che_귀환.ts @@ -57,7 +57,10 @@ export class ActionResolver< // Logic: specific officer levels return to their assigned city // 2 (Chief?), 3 (Staff?), 4 (Governor/Taishu) if (officerLevel >= 2 && officerLevel <= 4) { - const officerCity = readMetaNumberFromUnknown(general.meta, 'officer_city'); + const officerCity = + readMetaNumberFromUnknown(general.meta, 'officer_city') ?? + readMetaNumberFromUnknown(general.meta, 'officerCity') ?? + readMetaNumberFromUnknown(general.meta, 'officerCityId'); if (officerCity) { destCityId = officerCity; } @@ -180,9 +183,7 @@ export class ActionDefinition< export const actionContextBuilder: ActionContextBuilder = (base, options) => { return { ...base, - // We effectively need all cities to find the destination name if it's not the capital. - // Or at least nation cities. - nationCities: options.worldRef?.listCities().filter((c) => c.nationId === base.nation?.id) ?? [], + nationCities: options.worldRef?.listCities() ?? [], }; }; diff --git a/packages/logic/src/actions/turn/general/che_방랑.ts b/packages/logic/src/actions/turn/general/che_방랑.ts index 85f9b2e..c6b2686 100644 --- a/packages/logic/src/actions/turn/general/che_방랑.ts +++ b/packages/logic/src/actions/turn/general/che_방랑.ts @@ -80,13 +80,13 @@ export class ActionResolver< const josaUl = JosaUtil.pick(nation.name, '을'); context.addLog(`${general.name}${josaYi} 방랑의 길을 떠납니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.RAWTEXT, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, }); context.addLog(`【방랑】${general.name}${josaUn} 방랑의 길을 떠납니다.`, { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, - format: LogFormat.RAWTEXT, + format: LogFormat.YEAR_MONTH, }); context.addLog(`${nation.name}${josaUl} 버리고 방랑`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index c6a8b95..cfe2ec9 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -662,6 +662,10 @@ export const notCapital = (checkCurrentCity = false): Constraint => ({ } if (targetCityId === nation.capitalCityId) { + const general = readGeneral(ctx, view); + if (checkCurrentCity && general && general.officerLevel >= 2 && general.officerLevel <= 4) { + return allow(); + } return { kind: 'deny', reason: '수도입니다.' }; } return allow(); diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index 6bba61e..fb2d38b 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -119,6 +119,11 @@ export const projectCoreDatabaseSnapshot = (rows: { experience: row.experience, dedication: row.dedication, officerLevel: row.officerLevel, + officerCityId: readNumber( + row, + 'officerCityId', + readNumber(meta, 'officer_city', readNumber(meta, 'officerCity', readNumber(meta, 'officerCityId'))) + ), betray: row.betray, personality: row.personality ?? null, specialDomestic: row.specialDomestic ?? null, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index fa8e5ca..a387b59 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -262,7 +262,21 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur 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')), + officerCityId: readNumber( + row, + 'officerCityId', + readNumber(meta, 'officerCityId', readNumber(meta, 'officerCity', readNumber(meta, 'officer_city'))) + ), + officerCity: readNumber( + row, + 'officerCityId', + readNumber(meta, 'officerCity', readNumber(meta, 'officer_city')) + ), + officer_city: readNumber( + row, + 'officerCityId', + readNumber(meta, 'officer_city', readNumber(meta, 'officerCity')) + ), block: readNumber(row, 'blockState', readNumber(meta, 'block')), }, ...(lastTurn ? { lastTurn } : {}), @@ -520,6 +534,11 @@ const projectWorld = ( dedication: toDatabaseInt(general.dedication), expLevel: readNumber(general.meta, 'explevel'), officerLevel: general.officerLevel, + officerCityId: readNumber( + general.meta, + 'officer_city', + readNumber(general.meta, 'officerCity', readNumber(general.meta, 'officerCityId')) + ), betray: readNumber(general.meta, 'betray'), personality: general.role.personality, specialDomestic: general.role.specialDomestic, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 812f3d6..c295a55 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -637,6 +637,276 @@ integration('general sightseeing event, RNG, value, and log parity', () => { ); }); +interface MovementBoundaryCase { + name: string; + action: 'che_강행' | 'che_귀환' | 'che_접경귀환' | 'che_집합' | 'che_방랑'; + args?: Record; + actorPatch?: Record; + fixturePatches?: FixturePatches; + completed: boolean; + fallback?: boolean; +} + +const movementBoundaryCases: MovementBoundaryCase[] = [ + { + name: 'forced move accepts a numeric-string city ID', + action: 'che_강행', + args: { destCityID: '70' }, + completed: true, + }, + { + name: 'forced move truncates a fractional city ID', + action: 'che_강행', + args: { destCityID: 70.9 }, + completed: true, + }, + { + name: 'forced move rejects the current city', + action: 'che_강행', + args: { destCityID: 3 }, + completed: false, + }, + { + name: 'forced move rejects an unknown city', + action: 'che_강행', + args: { destCityID: 999 }, + completed: false, + }, + { + name: 'forced move rejects insufficient gold', + action: 'che_강행', + args: { destCityID: 70 }, + actorPatch: { gold: 0 }, + completed: false, + }, + { + name: 'forced move preserves the training and morale floors', + action: 'che_강행', + args: { destCityID: 70 }, + actorPatch: { train: 20, atmos: 20 }, + completed: true, + }, + { + name: 'forced move carries every wandering-nation general', + action: 'che_강행', + args: { destCityID: 70 }, + fixturePatches: { nations: { 1: { level: 0 } } }, + completed: true, + }, + { + name: 'return rejects a neutral actor', + action: 'che_귀환', + actorPatch: { nationId: 0 }, + completed: false, + }, + { + name: 'return rejects a wandering nation', + action: 'che_귀환', + fixturePatches: { nations: { 1: { level: 0 } } }, + completed: false, + }, + { + name: 'return rejects a lord already in the capital', + action: 'che_귀환', + completed: false, + }, + { + name: 'return lets a local officer leave the capital for the assigned city', + action: 'che_귀환', + actorPatch: { officerLevel: 2, officerCityId: 70 }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: true, + }, + { + name: 'return rewards an officer already in the assigned non-capital city', + action: 'che_귀환', + actorPatch: { officerLevel: 4, officerCityId: 70, cityId: 70 }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: true, + }, + { + name: 'return sends a normal officer to the capital', + action: 'che_귀환', + actorPatch: { officerLevel: 1, cityId: 70 }, + completed: true, + }, + { + name: 'border return rejects a neutral actor', + action: 'che_접경귀환', + actorPatch: { nationId: 0, cityId: 70 }, + completed: false, + }, + { + name: 'border return rejects a wandering nation', + action: 'che_접경귀환', + actorPatch: { cityId: 70 }, + fixturePatches: { nations: { 1: { level: 0 } } }, + completed: false, + }, + { + name: 'border return rejects an actor already in an owned city', + action: 'che_접경귀환', + completed: false, + }, + { + name: 'border return fails without a supplied owned city in range', + action: 'che_접경귀환', + actorPatch: { cityId: 70 }, + fixturePatches: { cities: { 3: { supplyState: 0 } } }, + completed: false, + fallback: false, + }, + { + name: 'border return consumes the single-candidate choice and moves', + action: 'che_접경귀환', + actorPatch: { cityId: 70 }, + completed: true, + }, + { + name: 'assembly rejects a neutral actor', + action: 'che_집합', + actorPatch: { nationId: 0, troopId: 1 }, + fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] }, + completed: false, + }, + { + name: 'assembly rejects a foreign-occupied city', + action: 'che_집합', + actorPatch: { troopId: 1 }, + fixturePatches: { + cities: { 3: { nationId: 2 } }, + troops: [{ id: 1, nationId: 1, name: '조조군' }], + }, + completed: false, + }, + { + name: 'assembly rejects an unsupplied city', + action: 'che_집합', + actorPatch: { troopId: 1 }, + fixturePatches: { + cities: { 3: { supplyState: 0 } }, + troops: [{ id: 1, nationId: 1, name: '조조군' }], + }, + completed: false, + }, + { + name: 'assembly rejects a non-leader', + action: 'che_집합', + actorPatch: { troopId: 3 }, + fixturePatches: { + generals: { 3: { troopId: 3 } }, + troops: [{ id: 3, nationId: 1, name: '조조군' }], + }, + completed: false, + }, + { + name: 'assembly rejects a troop without another member', + action: 'che_집합', + actorPatch: { troopId: 1 }, + fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] }, + completed: false, + }, + { + name: 'assembly completes when every member is already present', + action: 'che_집합', + actorPatch: { troopId: 1 }, + fixturePatches: { + generals: { 3: { troopId: 1 } }, + troops: [{ id: 1, nationId: 1, name: '조조군' }], + }, + completed: true, + }, + { + name: 'assembly moves a remote member and records its plain log', + action: 'che_집합', + actorPatch: { troopId: 1 }, + fixturePatches: { + generals: { 3: { cityId: 70, troopId: 1 } }, + troops: [{ id: 1, nationId: 1, name: '조조군' }], + }, + completed: true, + }, + { + name: 'wander rejects a non-lord', + action: 'che_방랑', + actorPatch: { officerLevel: 11 }, + fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } }, + completed: false, + }, + { + name: 'wander rejects an already wandering nation', + action: 'che_방랑', + fixturePatches: { + nations: { 1: { level: 0 } }, + diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } }, + }, + completed: false, + }, + { + name: 'wander rejects the opening phase', + action: 'che_방랑', + fixturePatches: { + world: { year: 180 }, + diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } }, + }, + completed: false, + }, + { + name: 'wander rejects a disallowed diplomacy state', + action: 'che_방랑', + completed: false, + }, + { + name: 'wander accepts neutral diplomacy', + action: 'che_방랑', + fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } }, + completed: true, + }, + { + name: 'wander accepts non-aggression diplomacy', + action: 'che_방랑', + fixturePatches: { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 7 } } }, + completed: true, + }, +]; + +integration('general movement command boundary and log parity', () => { + it.each(movementBoundaryCases)( + '$name', + async ({ name, action, args, actorPatch, fixturePatches, completed, fallback }) => { + const request = buildRequest(action, args, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = `general-movement-${name}`; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const usedFallback = fallback ?? !completed; + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: usedFallback ? '휴식' : action, + usedFallback, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (completed) { + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + integration('명장일람 rank_data command parity', () => { it('화계 increments firenum from the same seeded value as legacy', async () => { const request = buildRequest(