Files
core2026/docs/architecture/legacy-engine-constraints.md
T

165 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Legacy Constraint System
This document describes the constraint framework used to validate command
preconditions. References include `legacy/hwe/sammo/Constraint/Constraint.php`
and `legacy/hwe/sammo/Constraint/ConstraintHelper.php`.
## Core Concepts
Constraints are reusable predicate classes that validate a commands inputs and
state. They are built per-command and executed in order:
- Each constraint extends `Constraint` and implements `test()`.
- `Constraint::testAll()` iterates a list of constraints and returns the first
failure `[constraintName, reason]` or `null` for success.
- `BaseCommand::hasFullConditionMet()` uses these to decide command validity.
## Required Input Flags
Constraints declare required inputs using bit flags:
- `REQ_GENERAL`, `REQ_CITY`, `REQ_NATION`
- `REQ_DEST_GENERAL`, `REQ_DEST_CITY`, `REQ_DEST_NATION`
- `REQ_ARG` with typed sub-flags:
- `REQ_STRING_ARG`, `REQ_INT_ARG`, `REQ_NUMERIC_ARG`, `REQ_BOOLEAN_ARG`,
`REQ_ARRAY_ARG`, `REQ_BACKED_ENUM_ARG`
`Constraint::checkInputValues()` enforces these expectations and throws if
inputs are missing or malformed.
## Constraint Helper DSL
`ConstraintHelper` provides factory-style helpers used in command definitions:
- Examples: `AllowWar()`, `NearCity($distance)`, `ReqGeneralGold($amount)`,
`NotOccupiedDestCity()`, `AllowDiplomacyStatus(...)`, etc.
- These helpers return `[ConstraintName, arg]` tuples consumed by
`Constraint::testAll()`.
## Common Constraint Classes
Constraints are organized by domain:
- **Diplomacy**: `AllowDiplomacyStatus`, `AllowDiplomacyBetweenStatus`.
- **Nation/City**: `OccupiedCity`, `NotCapital`, `RemainCityCapacity`.
- **General**: `ReqGeneralCrew`, `ReqGeneralGold`, `MustBeTroopLeader`.
- **Routing**: `HasRoute`, `HasRouteWithEnemy`.
Each constraint sets a failure reason string used by UI and logs.
## Rewrite Constraint Contract (Draft)
This section proposes a shared contract for the rewrite so both the turn daemon
(in-memory) and API server (DB-backed) can evaluate constraints consistently.
### Goals
- Single source of truth for constraint logic.
- Support full evaluation in daemon and precheck in API.
- Explicit data requirements for batching and caching.
- Deterministic reasons for deny vs unknown outcomes.
### Types (TypeScript sketch)
```ts
export type ConstraintResult =
| { kind: 'allow' }
| { kind: 'deny'; reason: string; code?: string }
| { kind: 'unknown'; missing: RequirementKey[] };
export type RequirementKey =
| { kind: 'general'; id: number }
| { kind: 'city'; id: number }
| { kind: 'nation'; id: number }
| { kind: 'destGeneral'; id: number }
| { kind: 'destCity'; id: number }
| { kind: 'destNation'; id: number }
| { kind: 'arg'; key: string }
| { kind: 'env'; key: string };
export interface ConstraintContext {
actorId: number;
cityId?: number;
nationId?: number;
destGeneralId?: number;
destCityId?: number;
destNationId?: number;
args: Record<string, unknown>;
env: Record<string, unknown>;
mode: 'full' | 'precheck';
}
export interface StateView {
has(req: RequirementKey): boolean;
get(req: RequirementKey): unknown | null;
}
export interface Constraint {
name: string;
requires(ctx: ConstraintContext): RequirementKey[];
test(ctx: ConstraintContext, view: StateView): ConstraintResult;
}
```
### Evaluation Flow
- `ConstraintPlanner` collects requirements across constraints.
- `StateView` loads those requirements (daemon: in-memory, API: DB).
- `test()` returns:
- `allow` if constraint passes.
- `deny` with a stable reason/code for UI.
- `unknown` if required data is missing and `mode === 'precheck'`.
```ts
function evaluateConstraints(
constraints: Constraint[],
ctx: ConstraintContext,
view: StateView,
): ConstraintResult {
for (const constraint of constraints) {
const missing = constraint.requires(ctx).filter((req) => !view.has(req));
if (missing.length && ctx.mode === 'precheck') {
return { kind: 'unknown', missing };
}
const result = constraint.test(ctx, view);
if (result.kind !== 'allow') {
return result;
}
}
return { kind: 'allow' };
}
```
### StateView Selection Boundary
The split between in-memory and DB-backed evaluation happens outside the
constraint logic. A factory or loader chooses the `StateView` implementation
based on the execution environment:
- Turn daemon -> `InMemoryStateView` with a full in-memory snapshot.
- API server -> `DbStateView` (or `ProjectedStateView`) that fetches only the
required fields from the DB or precomputed projections.
This keeps constraints pure and deterministic, while the infrastructure layer
decides how to satisfy `requires()` in each runtime.
### Mapping from Legacy Flags
- `REQ_GENERAL` -> `{ kind: 'general', id: actorId }`
- `REQ_CITY` -> `{ kind: 'city', id: cityId }`
- `REQ_NATION` -> `{ kind: 'nation', id: nationId }`
- `REQ_DEST_*` -> respective `dest` key
- `REQ_ARG` -> `{ kind: 'arg', key: <argName> }`
- `env` dependencies (for example `turnterm`, `year`) -> `{ kind: 'env', key: 'turnterm' }`
### Data Projection Suggestion
- API prechecks can rely on a small read model (for example `general_summary`)
updated by the turn daemon; `StateView` selects the source per requirement.
## Open Questions / Follow-ups
- Some constraints rely on `env` values (`turnterm`, `year`, etc.); document
each commands exact `env` payload when porting.