feat: enhance action context with diplomacy and map handling, add constraints for crew and route validation
This commit is contained in:
@@ -403,6 +403,7 @@ const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
|
|||||||
destCity.nationId > 0
|
destCity.nationId > 0
|
||||||
? options.worldRef.getNationById(destCity.nationId)
|
? options.worldRef.getNationById(destCity.nationId)
|
||||||
: null;
|
: null;
|
||||||
|
const diplomacy = options.worldRef.listDiplomacy();
|
||||||
const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet);
|
const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet);
|
||||||
const aftermathConfig = buildWarAftermathConfig(
|
const aftermathConfig = buildWarAftermathConfig(
|
||||||
options.scenarioConfig,
|
options.scenarioConfig,
|
||||||
@@ -416,6 +417,8 @@ const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
|
|||||||
nations: options.worldRef.listNations(),
|
nations: options.worldRef.listNations(),
|
||||||
generals: options.worldRef.listGenerals(),
|
generals: options.worldRef.listGenerals(),
|
||||||
unitSet: options.unitSet,
|
unitSet: options.unitSet,
|
||||||
|
map: options.map,
|
||||||
|
diplomacy,
|
||||||
time: buildWarTime(options.world, options.scenarioMeta),
|
time: buildWarTime(options.world, options.scenarioMeta),
|
||||||
seedBase: options.seedBase,
|
seedBase: options.seedBase,
|
||||||
warConfig,
|
warConfig,
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ const asRecord = (value: unknown): Record<string, unknown> =>
|
|||||||
|
|
||||||
const resolveConstraintEnv = (
|
const resolveConstraintEnv = (
|
||||||
world: TurnWorldState,
|
world: TurnWorldState,
|
||||||
scenarioMeta?: ScenarioMeta
|
scenarioMeta: ScenarioMeta | undefined,
|
||||||
|
openingPartYear: number
|
||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
const startYear =
|
const startYear =
|
||||||
typeof scenarioMeta?.startYear === 'number'
|
typeof scenarioMeta?.startYear === 'number'
|
||||||
@@ -60,6 +61,7 @@ const resolveConstraintEnv = (
|
|||||||
month: world.currentMonth,
|
month: world.currentMonth,
|
||||||
startYear,
|
startYear,
|
||||||
relYear,
|
relYear,
|
||||||
|
openingPartYear,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,10 +235,15 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
execute(context): GeneralTurnResult {
|
execute(context): GeneralTurnResult {
|
||||||
const worldRef = options.getWorld();
|
const worldRef = options.getWorld();
|
||||||
const constraintEnv = {
|
const constraintEnv = {
|
||||||
...resolveConstraintEnv(context.world, options.scenarioMeta),
|
...resolveConstraintEnv(
|
||||||
|
context.world,
|
||||||
|
options.scenarioMeta,
|
||||||
|
env.openingPartYear
|
||||||
|
),
|
||||||
...(options.map ? { map: options.map } : {}),
|
...(options.map ? { map: options.map } : {}),
|
||||||
...(options.unitSet ? { unitSet: options.unitSet } : {}),
|
...(options.unitSet ? { unitSet: options.unitSet } : {}),
|
||||||
cities: worldRef?.listCities() ?? [],
|
cities: worldRef?.listCities() ?? [],
|
||||||
|
nations: worldRef?.listNations() ?? [],
|
||||||
};
|
};
|
||||||
const logs: LogEntryDraft[] = [];
|
const logs: LogEntryDraft[] = [];
|
||||||
const patches = {
|
const patches = {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Move items into the main docs once they are finalized.
|
|||||||
- Deterministic RNG test harness guidelines
|
- Deterministic RNG test harness guidelines
|
||||||
- Output comparison rules (sorting, tolerances, diff granularity)
|
- Output comparison rules (sorting, tolerances, diff granularity)
|
||||||
- Unit test vs simulation test split and responsibilities
|
- 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
|
## Trigger System
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,24 @@ import type {
|
|||||||
GeneralTriggerState,
|
GeneralTriggerState,
|
||||||
Nation,
|
Nation,
|
||||||
} from '../../../domain/entities.js';
|
} from '../../../domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '../../../constraints/types.js';
|
import type {
|
||||||
|
Constraint,
|
||||||
|
ConstraintContext,
|
||||||
|
StateView,
|
||||||
|
} from '../../../constraints/types.js';
|
||||||
import {
|
import {
|
||||||
existsDestCity,
|
existsDestCity,
|
||||||
|
hasRouteWithEnemy,
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
notOccupiedDestCity,
|
notOccupiedDestCity,
|
||||||
|
notOpeningPart,
|
||||||
|
notSameDestCity,
|
||||||
occupiedCity,
|
occupiedCity,
|
||||||
|
reqGeneralCrew,
|
||||||
|
reqGeneralRice,
|
||||||
suppliedCity,
|
suppliedCity,
|
||||||
} from '../../../constraints/presets.js';
|
} from '../../../constraints/presets.js';
|
||||||
|
import { readGeneral } from '../../../constraints/helpers.js';
|
||||||
import type { GeneralActionDefinition } from '../../definition.js';
|
import type { GeneralActionDefinition } from '../../definition.js';
|
||||||
import type {
|
import type {
|
||||||
GeneralActionEffect,
|
GeneralActionEffect,
|
||||||
@@ -24,6 +34,7 @@ import {
|
|||||||
createGeneralPatchEffect,
|
createGeneralPatchEffect,
|
||||||
createNationPatchEffect,
|
createNationPatchEffect,
|
||||||
} from '../../engine.js';
|
} from '../../engine.js';
|
||||||
|
import { JosaUtil, LiteHashDRBG } from '@sammo-ts/common';
|
||||||
import type { TurnCommandEnv } from '../commandEnv.js';
|
import type { TurnCommandEnv } from '../commandEnv.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -33,8 +44,14 @@ import type {
|
|||||||
} from '../../../war/types.js';
|
} from '../../../war/types.js';
|
||||||
import { resolveWarAftermath } from '../../../war/aftermath.js';
|
import { resolveWarAftermath } from '../../../war/aftermath.js';
|
||||||
import { resolveWarBattle } from '../../../war/engine.js';
|
import { resolveWarBattle } from '../../../war/engine.js';
|
||||||
import { simpleSerialize } from '../../../war/utils.js';
|
import {
|
||||||
import type { UnitSetDefinition } from '../../../world/types.js';
|
increaseMetaNumber,
|
||||||
|
simpleSerialize,
|
||||||
|
} from '../../../war/utils.js';
|
||||||
|
import type {
|
||||||
|
MapDefinition,
|
||||||
|
UnitSetDefinition,
|
||||||
|
} from '../../../world/types.js';
|
||||||
|
|
||||||
export interface DispatchArgs {
|
export interface DispatchArgs {
|
||||||
destCityId: number;
|
destCityId: number;
|
||||||
@@ -49,6 +66,8 @@ export interface DispatchResolveContext<
|
|||||||
nations: Nation[];
|
nations: Nation[];
|
||||||
generals: General<TriggerState>[];
|
generals: General<TriggerState>[];
|
||||||
unitSet: UnitSetDefinition;
|
unitSet: UnitSetDefinition;
|
||||||
|
map?: MapDefinition;
|
||||||
|
diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number }>;
|
||||||
time: WarTimeContext;
|
time: WarTimeContext;
|
||||||
seedBase: string;
|
seedBase: string;
|
||||||
warConfig: WarEngineConfig;
|
warConfig: WarEngineConfig;
|
||||||
@@ -64,6 +83,144 @@ const parseCityId = (raw: unknown): number | null => {
|
|||||||
return raw > 0 ? Math.floor(raw) : 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>(
|
const cloneGeneral = <TriggerState extends GeneralTriggerState>(
|
||||||
general: General<TriggerState>
|
general: General<TriggerState>
|
||||||
): General<TriggerState> => ({
|
): General<TriggerState> => ({
|
||||||
@@ -116,12 +273,22 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: DispatchArgs): Constraint[] {
|
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 [
|
return [
|
||||||
|
notOpeningPart(relYear, openingPartYear),
|
||||||
|
notSameDestCity(),
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
occupiedCity(),
|
occupiedCity(),
|
||||||
suppliedCity(),
|
suppliedCity(),
|
||||||
|
reqGeneralCrew(),
|
||||||
|
reqGeneralRice(getRequiredRice),
|
||||||
existsDestCity(),
|
existsDestCity(),
|
||||||
notOccupiedDestCity(),
|
notOccupiedDestCity(),
|
||||||
|
hasRouteWithEnemy(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,17 +306,104 @@ export class ActionDefinition<
|
|||||||
throw new Error('Dispatch requires a nation context.');
|
throw new Error('Dispatch requires a nation context.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const destCity = context.destCity;
|
const finalTargetCity = context.destCity;
|
||||||
const unitSet = context.unitSet;
|
const unitSet = context.unitSet;
|
||||||
const time = context.time;
|
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,
|
context.seedBase,
|
||||||
this.key,
|
'war',
|
||||||
time.year,
|
time.year,
|
||||||
time.month,
|
time.month,
|
||||||
context.general.id,
|
context.general.id,
|
||||||
destCity.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 cities = context.cities.map(cloneCity);
|
||||||
const nations = context.nations.map(cloneNation);
|
const nations = context.nations.map(cloneNation);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { City, General } from '../domain/entities.js';
|
import type { City, General } from '../domain/entities.js';
|
||||||
|
import type { MapDefinition } from '../world/types.js';
|
||||||
import {
|
import {
|
||||||
allow,
|
allow,
|
||||||
parsePercent,
|
parsePercent,
|
||||||
readCity,
|
readCity,
|
||||||
readDestCity,
|
readDestCity,
|
||||||
|
readDiplomacyState,
|
||||||
|
readGeneral,
|
||||||
readMetaNumberFromUnknown,
|
readMetaNumberFromUnknown,
|
||||||
resolveDestCityId,
|
resolveDestCityId,
|
||||||
unknownOrDeny,
|
unknownOrDeny,
|
||||||
@@ -362,3 +365,160 @@ export const notNeutralDestCity = (): Constraint => ({
|
|||||||
return { kind: 'deny', reason: '공백지입니다.' };
|
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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = (
|
export const reqGeneralCrewMargin = (
|
||||||
getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null,
|
getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null,
|
||||||
requirements: RequirementKey[] = []
|
requirements: RequirementKey[] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user