feat: enhance action context with diplomacy and map handling, add constraints for crew and route validation

This commit is contained in:
2026-01-02 19:52:17 +00:00
parent ea2ef91b31
commit edcfad4e40
6 changed files with 452 additions and 8 deletions
@@ -403,6 +403,7 @@ const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
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<string, ActionContextBuilder> = {
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,
@@ -42,7 +42,8 @@ const asRecord = (value: unknown): Record<string, unknown> =>
const resolveConstraintEnv = (
world: TurnWorldState,
scenarioMeta?: ScenarioMeta
scenarioMeta: ScenarioMeta | undefined,
openingPartYear: number
): Record<string, unknown> => {
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 = {
+1
View File
@@ -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
@@ -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<TriggerState>[];
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<number>([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<number, number[]> => {
const index = new Map<number, number[]>();
for (const city of map.cities) {
index.set(city.id, Array.from(city.connections ?? []));
}
return index;
};
const searchDistanceListToDest = (
fromCityId: number,
toCityId: number,
mapIndex: Map<number, number[]>,
allowedCityIds: Map<number, number>
): Map<number, Array<[number, number]>> => {
if (!allowedCityIds.has(toCityId)) {
return new Map();
}
const remainFromCities = new Set<number>();
const fromNeighbors = mapIndex.get(fromCityId) ?? [];
for (const cityId of fromNeighbors) {
if (allowedCityIds.has(cityId)) {
remainFromCities.add(cityId);
}
}
const result = new Map<number, Array<[number, number]>>();
const queue: Array<[number, number]> = [[toCityId, 0]];
const visited = new Set<number>();
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<number, Array<[number, number]>>,
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 = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>
): General<TriggerState> => ({
@@ -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<number, number>();
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(
`본국입니다. <G><b>${destCity.name}</b></>${josaRo} 이동합니다.`
);
} else {
const targetName = finalTargetCity.name;
const josaRoTarget = JosaUtil.pick(targetName, '로');
context.addLog(
`가까운 경로에 적군 도시가 없습니다. <G><b>${destCity.name}</b></>${josaRo} 이동합니다.`
);
context.addLog(
`<G><b>${targetName}</b></>${josaRoTarget} 가는 도중 <G><b>${destCity.name}</b></>을 거치기로 합니다.`
);
}
return { effects: [] };
}
if (finalTargetCity.id !== destCity.id) {
const josaRo = JosaUtil.pick(finalTargetCity.name, '로');
const josaUl = JosaUtil.pick(destCity.name, '을');
if (minDist === 0) {
context.addLog(
`<G><b>${finalTargetCity.name}</b></>${josaRo} 가기 위해 <G><b>${destCity.name}</b></>${josaUl} 거쳐야 합니다.`
);
} else {
context.addLog(
`<G><b>${finalTargetCity.name}</b></>${josaRo} 가는 도중 <G><b>${destCity.name}</b></>${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);
+160
View File
@@ -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<number, number[]> => {
const index = new Map<number, number[]>();
for (const city of map.cities) {
index.set(city.id, Array.from(city.connections ?? []));
}
return index;
};
const hasRouteToDest = (
mapIndex: Map<number, number[]>,
allowedCityIds: Set<number>,
fromCityId: number,
toCityId: number
): boolean => {
if (fromCityId === toCityId) {
return true;
}
if (!allowedCityIds.has(toCityId)) {
return false;
}
const queue: number[] = [fromCityId];
const visited = new Set<number>();
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<number>();
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<number>();
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();
},
});
+19
View File
@@ -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[] = []