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
+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[] = []