From edcfad4e403210e28f77bf71f7c891a93ffe3b59 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 2 Jan 2026 19:52:17 +0000 Subject: [PATCH] feat: enhance action context with diplomacy and map handling, add constraints for crew and route validation --- .../src/turn/reservedTurnActionContext.ts | 3 + .../src/turn/reservedTurnHandler.ts | 11 +- docs/architecture/todo.md | 1 + .../src/actions/turn/general/che_출병.ts | 266 +++++++++++++++++- packages/logic/src/constraints/city.ts | 160 +++++++++++ packages/logic/src/constraints/general.ts | 19 ++ 6 files changed, 452 insertions(+), 8 deletions(-) diff --git a/app/game-engine/src/turn/reservedTurnActionContext.ts b/app/game-engine/src/turn/reservedTurnActionContext.ts index ec0fa04..08176f3 100644 --- a/app/game-engine/src/turn/reservedTurnActionContext.ts +++ b/app/game-engine/src/turn/reservedTurnActionContext.ts @@ -403,6 +403,7 @@ const ACTION_CONTEXT_BUILDERS: Record = { destCity.nationId > 0 ? options.worldRef.getNationById(destCity.nationId) : null; + const diplomacy = options.worldRef.listDiplomacy(); const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet); const aftermathConfig = buildWarAftermathConfig( options.scenarioConfig, @@ -416,6 +417,8 @@ const ACTION_CONTEXT_BUILDERS: Record = { nations: options.worldRef.listNations(), generals: options.worldRef.listGenerals(), unitSet: options.unitSet, + map: options.map, + diplomacy, time: buildWarTime(options.world, options.scenarioMeta), seedBase: options.seedBase, warConfig, diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 3a29c63..70a55a7 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -42,7 +42,8 @@ const asRecord = (value: unknown): Record => const resolveConstraintEnv = ( world: TurnWorldState, - scenarioMeta?: ScenarioMeta + scenarioMeta: ScenarioMeta | undefined, + openingPartYear: number ): Record => { const startYear = typeof scenarioMeta?.startYear === 'number' @@ -60,6 +61,7 @@ const resolveConstraintEnv = ( month: world.currentMonth, startYear, relYear, + openingPartYear, }; }; @@ -233,10 +235,15 @@ export const createReservedTurnHandler = async (options: { execute(context): GeneralTurnResult { const worldRef = options.getWorld(); const constraintEnv = { - ...resolveConstraintEnv(context.world, options.scenarioMeta), + ...resolveConstraintEnv( + context.world, + options.scenarioMeta, + env.openingPartYear + ), ...(options.map ? { map: options.map } : {}), ...(options.unitSet ? { unitSet: options.unitSet } : {}), cities: worldRef?.listCities() ?? [], + nations: worldRef?.listNations() ?? [], }; const logs: LogEntryDraft[] = []; const patches = { diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index 7390588..7a4dda2 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -35,6 +35,7 @@ Move items into the main docs once they are finalized. - Deterministic RNG test harness guidelines - Output comparison rules (sorting, tolerances, diff granularity) - Unit test vs simulation test split and responsibilities +- [AI suggestion] Document che_출병 parity gaps vs legacy (city state/term=43, fallback to che_이동 when friendly, nation.war/AllowWar check, post-war static events/unique-item lottery, missing route data when map/diplomacy not provided). ## Trigger System diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 2332687..5c2325d 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -4,14 +4,24 @@ import type { GeneralTriggerState, Nation, } from '../../../domain/entities.js'; -import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '../../../constraints/types.js'; import { existsDestCity, + hasRouteWithEnemy, notBeNeutral, notOccupiedDestCity, + notOpeningPart, + notSameDestCity, occupiedCity, + reqGeneralCrew, + reqGeneralRice, suppliedCity, } from '../../../constraints/presets.js'; +import { readGeneral } from '../../../constraints/helpers.js'; import type { GeneralActionDefinition } from '../../definition.js'; import type { GeneralActionEffect, @@ -24,6 +34,7 @@ import { createGeneralPatchEffect, createNationPatchEffect, } from '../../engine.js'; +import { JosaUtil, LiteHashDRBG } from '@sammo-ts/common'; import type { TurnCommandEnv } from '../commandEnv.js'; import type { GeneralTurnCommandSpec } from './index.js'; import type { @@ -33,8 +44,14 @@ import type { } from '../../../war/types.js'; import { resolveWarAftermath } from '../../../war/aftermath.js'; import { resolveWarBattle } from '../../../war/engine.js'; -import { simpleSerialize } from '../../../war/utils.js'; -import type { UnitSetDefinition } from '../../../world/types.js'; +import { + increaseMetaNumber, + simpleSerialize, +} from '../../../war/utils.js'; +import type { + MapDefinition, + UnitSetDefinition, +} from '../../../world/types.js'; export interface DispatchArgs { destCityId: number; @@ -49,6 +66,8 @@ export interface DispatchResolveContext< nations: Nation[]; generals: General[]; unitSet: UnitSetDefinition; + map?: MapDefinition; + diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number }>; time: WarTimeContext; seedBase: string; warConfig: WarEngineConfig; @@ -64,6 +83,144 @@ const parseCityId = (raw: unknown): number | null => { return raw > 0 ? Math.floor(raw) : null; }; +const toHex = (bytes: Uint8Array): string => + Array.from(bytes) + .map((value) => value.toString(16).padStart(2, '0')) + .join(''); + +const buildAllowedNationIds = ( + attackerNationId: number, + diplomacy: Array<{ fromNationId: number; toNationId: number; state: number }> +): number[] => { + const allowed = new Set([attackerNationId, 0]); + for (const entry of diplomacy) { + if (entry.fromNationId === attackerNationId && entry.state === 0) { + allowed.add(entry.toNationId); + } + } + return Array.from(allowed); +}; + +const buildMapIndex = (map: MapDefinition): Map => { + const index = new Map(); + for (const city of map.cities) { + index.set(city.id, Array.from(city.connections ?? [])); + } + return index; +}; + +const searchDistanceListToDest = ( + fromCityId: number, + toCityId: number, + mapIndex: Map, + allowedCityIds: Map +): Map> => { + if (!allowedCityIds.has(toCityId)) { + return new Map(); + } + const remainFromCities = new Set(); + const fromNeighbors = mapIndex.get(fromCityId) ?? []; + for (const cityId of fromNeighbors) { + if (allowedCityIds.has(cityId)) { + remainFromCities.add(cityId); + } + } + const result = new Map>(); + const queue: Array<[number, number]> = [[toCityId, 0]]; + const visited = new Set(); + + while (remainFromCities.size > 0 && queue.length > 0) { + const next = queue.shift(); + if (!next) { + continue; + } + const [cityId, dist] = next; + if (visited.has(cityId)) { + continue; + } + visited.add(cityId); + if (remainFromCities.has(cityId)) { + remainFromCities.delete(cityId); + const nationId = allowedCityIds.get(cityId) ?? 0; + const list = result.get(dist); + if (list) { + list.push([cityId, nationId]); + } else { + result.set(dist, [[cityId, nationId]]); + } + } + const neighbors = mapIndex.get(cityId) ?? []; + for (const neighbor of neighbors) { + if (!allowedCityIds.has(neighbor)) { + continue; + } + if (!visited.has(neighbor)) { + queue.push([neighbor, dist + 1]); + } + } + } + + return result; +}; + +const pickCandidateCity = ( + rng: DispatchResolveContext['rng'], + distanceList: Map>, + attackerNationId: number +): { cityId: number; isEnemy: boolean; minDist: number } | null => { + const distances = Array.from(distanceList.keys()).sort((a, b) => a - b); + const minDist = distances[0]; + if (minDist === undefined) { + return null; + } + const candidates: Array<[number, number]> = []; + for (const dist of distances) { + if (dist > minDist + 1) { + break; + } + for (const entry of distanceList.get(dist) ?? []) { + if (entry[1] !== attackerNationId) { + candidates.push(entry); + } + } + } + if (candidates.length > 0) { + const index = rng.nextInt(0, candidates.length); + const [cityId] = candidates[index] ?? candidates[0]!; + return { cityId, isEnemy: true, minDist }; + } + const fallback = distanceList.get(minDist) ?? []; + const friendly = fallback.filter( + ([, nationId]) => nationId === attackerNationId + ); + if (friendly.length === 0) { + return null; + } + const index = rng.nextInt(0, friendly.length); + const [cityId] = friendly[index] ?? friendly[0]!; + return { cityId, isEnemy: false, minDist }; +}; + +const getRequiredRice = (ctx: ConstraintContext, view: StateView): number => { + const general = readGeneral(ctx, view); + if (!general) { + return 0; + } + return Math.round(general.crew / 100); +}; + +const resolveCrewTypeArm = ( + unitSet: UnitSetDefinition, + crewTypeId: number +): number | null => { + const crewTypes = unitSet.crewTypes ?? []; + const crewType = crewTypes.find((entry) => entry.id === crewTypeId); + if (!crewType) { + return null; + } + return crewType.armType; +}; + const cloneGeneral = ( general: General ): General => ({ @@ -116,12 +273,22 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, _args: DispatchArgs): Constraint[] { + const relYear = typeof _ctx.env.relYear === 'number' ? _ctx.env.relYear : 0; + const openingPartYear = + typeof _ctx.env.openingPartYear === 'number' + ? _ctx.env.openingPartYear + : 0; return [ + notOpeningPart(relYear, openingPartYear), + notSameDestCity(), notBeNeutral(), occupiedCity(), suppliedCity(), + reqGeneralCrew(), + reqGeneralRice(getRequiredRice), existsDestCity(), notOccupiedDestCity(), + hasRouteWithEnemy(), ]; } @@ -139,17 +306,104 @@ export class ActionDefinition< throw new Error('Dispatch requires a nation context.'); } - const destCity = context.destCity; + const finalTargetCity = context.destCity; const unitSet = context.unitSet; const time = context.time; - const seed = simpleSerialize( + const diplomacy = context.diplomacy ?? []; + const allowedNationIds = buildAllowedNationIds( + attackerNation.id, + diplomacy + ); + const mapIndex = context.map ? buildMapIndex(context.map) : null; + + let defenderCityId = finalTargetCity.id; + let minDist = 0; + let isEnemyTarget = finalTargetCity.nationId !== attackerNation.id; + + if (mapIndex) { + const allowedCityIds = new Map(); + for (const city of context.cities) { + if (allowedNationIds.includes(city.nationId)) { + allowedCityIds.set(city.id, city.nationId); + } + } + const distanceList = searchDistanceListToDest( + attackerCity.id, + finalTargetCity.id, + mapIndex, + allowedCityIds + ); + const picked = pickCandidateCity( + context.rng, + distanceList, + attackerNation.id + ); + if (!picked) { + context.addLog('경로에 도달할 방법이 없습니다.'); + return { effects: [] }; + } + defenderCityId = picked.cityId; + minDist = picked.minDist; + isEnemyTarget = picked.isEnemy; + } + + const destCity = + defenderCityId === finalTargetCity.id + ? finalTargetCity + : context.cities.find((city) => city.id === defenderCityId) ?? + finalTargetCity; + + if (!isEnemyTarget && destCity.nationId === attackerNation.id) { + const josaRo = JosaUtil.pick(destCity.name, '로'); + if (finalTargetCity.id === destCity.id) { + context.addLog( + `본국입니다. ${destCity.name}${josaRo} 이동합니다.` + ); + } else { + const targetName = finalTargetCity.name; + const josaRoTarget = JosaUtil.pick(targetName, '로'); + context.addLog( + `가까운 경로에 적군 도시가 없습니다. ${destCity.name}${josaRo} 이동합니다.` + ); + context.addLog( + `${targetName}${josaRoTarget} 가는 도중 ${destCity.name}을 거치기로 합니다.` + ); + } + return { effects: [] }; + } + + if (finalTargetCity.id !== destCity.id) { + const josaRo = JosaUtil.pick(finalTargetCity.name, '로'); + const josaUl = JosaUtil.pick(destCity.name, '을'); + if (minDist === 0) { + context.addLog( + `${finalTargetCity.name}${josaRo} 가기 위해 ${destCity.name}${josaUl} 거쳐야 합니다.` + ); + } else { + context.addLog( + `${finalTargetCity.name}${josaRo} 가는 도중 ${destCity.name}${josaUl} 거치기로 합니다.` + ); + } + } + + const preSeed = simpleSerialize( context.seedBase, - this.key, + 'war', time.year, time.month, context.general.id, destCity.id ); + const seed = toHex(LiteHashDRBG.build(preSeed).nextBytes(16)); + + const armType = resolveCrewTypeArm(unitSet, context.general.crewTypeId); + if (armType !== null) { + increaseMetaNumber( + context.general.meta, + `dex${armType}`, + context.general.crew / 100 + ); + } const cities = context.cities.map(cloneCity); const nations = context.nations.map(cloneNation); diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index 4927054..81ee711 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -1,9 +1,12 @@ import type { City, General } from '../domain/entities.js'; +import type { MapDefinition } from '../world/types.js'; import { allow, parsePercent, readCity, readDestCity, + readDiplomacyState, + readGeneral, readMetaNumberFromUnknown, resolveDestCityId, unknownOrDeny, @@ -362,3 +365,160 @@ export const notNeutralDestCity = (): Constraint => ({ return { kind: 'deny', reason: '공백지입니다.' }; }, }); + +export const notSameDestCity = (): Constraint => ({ + name: 'NotSameDestCity', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + const destCityId = resolveDestCityId(ctx); + if (destCityId !== undefined) { + reqs.push({ kind: 'destCity', id: destCityId }); + } + return reqs; + }, + test: (ctx, view) => { + const general = readGeneral(ctx, view); + if (!general) { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const destCityId = resolveDestCityId(ctx); + if (destCityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + if (general.cityId !== destCityId) { + return allow(); + } + return { kind: 'deny', reason: '같은 도시입니다.' }; + }, +}); + +const buildMapIndex = (map: MapDefinition): Map => { + const index = new Map(); + for (const city of map.cities) { + index.set(city.id, Array.from(city.connections ?? [])); + } + return index; +}; + +const hasRouteToDest = ( + mapIndex: Map, + allowedCityIds: Set, + fromCityId: number, + toCityId: number +): boolean => { + if (fromCityId === toCityId) { + return true; + } + if (!allowedCityIds.has(toCityId)) { + return false; + } + const queue: number[] = [fromCityId]; + const visited = new Set(); + while (queue.length > 0) { + const current = queue.shift(); + if (current === undefined) { + continue; + } + if (visited.has(current)) { + continue; + } + visited.add(current); + const neighbors = mapIndex.get(current) ?? []; + for (const next of neighbors) { + if (!allowedCityIds.has(next)) { + continue; + } + if (next === toCityId) { + return true; + } + if (!visited.has(next)) { + queue.push(next); + } + } + } + return false; +}; + +export const hasRouteWithEnemy = (): Constraint => ({ + name: 'HasRouteWithEnemy', + requires: (ctx) => { + const reqs: RequirementKey[] = [ + { kind: 'general', id: ctx.actorId }, + ]; + const destCityId = resolveDestCityId(ctx); + if (destCityId !== undefined) { + reqs.push({ kind: 'destCity', id: destCityId }); + } + reqs.push({ kind: 'env', key: 'map' }); + reqs.push({ kind: 'env', key: 'cities' }); + reqs.push({ kind: 'env', key: 'nations' }); + return reqs; + }, + test: (ctx, view) => { + const general = readGeneral(ctx, view); + if (!general) { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const destCity = readDestCity(ctx, view); + if (!destCity) { + const destCityId = resolveDestCityId(ctx); + if (destCityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'destCity', id: destCityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | null; + const cities = view.get({ kind: 'env', key: 'cities' }) as City[] | null; + const nations = view.get({ kind: 'env', key: 'nations' }) as + | Array<{ id: number }> + | null; + if (!map || !cities || !nations) { + return unknownOrDeny(ctx, [], '경로 정보가 없습니다.'); + } + + const allowedNationIds = new Set(); + allowedNationIds.add(general.nationId); + allowedNationIds.add(0); + for (const nation of nations) { + const state = readDiplomacyState( + view, + general.nationId, + nation.id + ); + if (state === 0) { + allowedNationIds.add(nation.id); + } + } + + if ( + destCity.nationId !== 0 && + destCity.nationId !== general.nationId && + !allowedNationIds.has(destCity.nationId) + ) { + return { kind: 'deny', reason: '교전중인 국가가 아닙니다.' }; + } + + const allowedCityIds = new Set(); + for (const city of cities) { + if (allowedNationIds.has(city.nationId)) { + allowedCityIds.add(city.id); + } + } + if (!allowedCityIds.has(destCity.id)) { + return { kind: 'deny', reason: '경로에 도달할 방법이 없습니다.' }; + } + + const mapIndex = buildMapIndex(map); + if (!mapIndex.has(general.cityId)) { + return unknownOrDeny(ctx, [], '경로 정보가 없습니다.'); + } + + if (!hasRouteToDest(mapIndex, allowedCityIds, general.cityId, destCity.id)) { + return { kind: 'deny', reason: '경로에 도달할 방법이 없습니다.' }; + } + return allow(); + }, +}); diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index 49969d2..39e20ea 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -117,6 +117,25 @@ export const reqGeneralRice = ( }, }); +export const reqGeneralCrew = (): Constraint => ({ + name: 'ReqGeneralCrew', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + if (general.crew > 0) { + return allow(); + } + return { kind: 'deny', reason: '병사가 모자랍니다.' }; + }, +}); + export const reqGeneralCrewMargin = ( getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null, requirements: RequirementKey[] = []