Spaces:
Runtime error
Runtime error
| """ | |
| behaviors.attack | |
| ================ | |
| Generic conflict behaviors for WorldSmithAI. | |
| This module implements domain-agnostic conflict primitives: | |
| - AttackBehavior | |
| - HuntBehavior | |
| - DefendBehavior | |
| - RaidBehavior | |
| These behaviors can model physical combat, economic rivalry, political attacks, | |
| reputation damage, market pressure, predation, raiding, sabotage, or defensive | |
| preparation. They do not assume hardcoded concepts such as health, armor, | |
| soldiers, wolves, weapons, money, or food. | |
| Model-friendly DSL examples | |
| --------------------------- | |
| Model-generated hunting: | |
| { | |
| "name": "hunt", | |
| "params": { | |
| "target": "deer", | |
| "effort": 1.0 | |
| } | |
| } | |
| Generic attack: | |
| { | |
| "name": "attack", | |
| "params": { | |
| "target": "wolf", | |
| "amount": 10.0, | |
| "target_state_key": "health", | |
| "mark_dead_on_threshold": true, | |
| "defeat_threshold": 0.0 | |
| } | |
| } | |
| Formal DSL-style attack: | |
| { | |
| "name": "attack", | |
| "params": { | |
| "target_selector": {"type": "bandit"}, | |
| "radius": 2.0, | |
| "target_state_key": "health", | |
| "amount": 2.0, | |
| "target_defense_state_key": "shield", | |
| "defense_multiplier": 0.5, | |
| "min_target_state_value": 0.0, | |
| "mark_dead_on_threshold": true, | |
| "defeat_threshold": 0.0 | |
| } | |
| } | |
| Defense: | |
| { | |
| "name": "defend", | |
| "params": { | |
| "defense_state_key": "shield", | |
| "amount": 1.5, | |
| "max_state_value": 10.0 | |
| } | |
| } | |
| Raid: | |
| { | |
| "name": "raid", | |
| "params": { | |
| "target_selector": {"type": "colony"}, | |
| "radius": 3.0, | |
| "raid_assets": [ | |
| { | |
| "container": "inventory", | |
| "key": "fuel", | |
| "amount": 2.0 | |
| } | |
| ], | |
| "allow_partial": true | |
| } | |
| } | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from collections.abc import Mapping, Sequence | |
| from copy import deepcopy | |
| from dataclasses import dataclass, field, fields as dataclass_fields | |
| from numbers import Real | |
| from types import MappingProxyType | |
| from typing import Any, ClassVar, Literal, TypeAlias | |
| import numpy as np | |
| from numpy.typing import NDArray | |
| from core.agent import Agent, WorldProtocol | |
| from core.behavior import Behavior, BehaviorResult | |
| logger = logging.getLogger(__name__) | |
| AssetContainer: TypeAlias = Literal["state", "inventory"] | |
| TargetSelectionStrategy: TypeAlias = Literal[ | |
| "nearest", | |
| "farthest", | |
| "random", | |
| "highest_state", | |
| "lowest_state", | |
| ] | |
| DEFAULT_INVENTORY_KEY: str = "inventory" | |
| DEFAULT_ATTACK_AMOUNT: float = 10.0 | |
| DEFAULT_DEFENSE_AMOUNT: float = 1.0 | |
| DEFAULT_RAID_AMOUNT: float = 1.0 | |
| ZERO_TOLERANCE: float = 1.0e-12 | |
| _BEHAVIOR_BASE_FIELDS: set[str] = { | |
| "id", | |
| "behavior_id", | |
| "name", | |
| "parameters", | |
| "metadata", | |
| "enabled", | |
| "priority", | |
| "tags", | |
| "dsl_spec", | |
| } | |
| class ConflictTargetCandidate: | |
| """Candidate target selected from the world.""" | |
| id: str | |
| type: str | |
| position: NDArray[np.float64] | |
| distance: float | |
| metadata: Mapping[str, Any] = field(default_factory=dict) | |
| def __post_init__(self) -> None: | |
| """Validate and normalize target candidate fields.""" | |
| if not isinstance(self.id, str) or not self.id.strip(): | |
| raise ValueError("ConflictTargetCandidate.id must be non-empty.") | |
| if not isinstance(self.type, str) or not self.type.strip(): | |
| raise ValueError("ConflictTargetCandidate.type must be non-empty.") | |
| position = np.asarray(self.position, dtype=np.float64).reshape(-1) | |
| if position.ndim != 1: | |
| raise ValueError("ConflictTargetCandidate.position must be one-dimensional.") | |
| if position.size == 0: | |
| raise ValueError("ConflictTargetCandidate.position cannot be empty.") | |
| if not np.all(np.isfinite(position)): | |
| raise ValueError("ConflictTargetCandidate.position must contain finite values.") | |
| if not np.isfinite(self.distance): | |
| raise ValueError("ConflictTargetCandidate.distance must be finite.") | |
| if self.distance < 0.0: | |
| raise ValueError("ConflictTargetCandidate.distance cannot be negative.") | |
| if not isinstance(self.metadata, Mapping): | |
| raise TypeError("ConflictTargetCandidate.metadata must be a mapping.") | |
| object.__setattr__(self, "id", self.id.strip()) | |
| object.__setattr__(self, "type", self.type.strip()) | |
| object.__setattr__(self, "position", position.astype(np.float64, copy=True)) | |
| object.__setattr__(self, "distance", float(self.distance)) | |
| object.__setattr__(self, "metadata", deepcopy(dict(self.metadata))) | |
| def to_metadata(self) -> dict[str, Any]: | |
| """Convert the target candidate into JSON-friendly metadata.""" | |
| return { | |
| "id": self.id, | |
| "type": self.type, | |
| "position": self.position.tolist(), | |
| "distance": self.distance, | |
| "metadata": deepcopy(dict(self.metadata)), | |
| } | |
| class RaidAsset: | |
| """Asset transferred during a raid.""" | |
| key: str | |
| amount: float | |
| container: AssetContainer = "inventory" | |
| inventory_key: str = DEFAULT_INVENTORY_KEY | |
| metadata: Mapping[str, Any] = field(default_factory=dict) | |
| def __post_init__(self) -> None: | |
| """Validate and normalize raid asset fields.""" | |
| if not isinstance(self.key, str) or not self.key.strip(): | |
| raise ValueError("RaidAsset.key must be a non-empty string.") | |
| if self.container not in {"state", "inventory"}: | |
| raise ValueError("RaidAsset.container must be 'state' or 'inventory'.") | |
| if not isinstance(self.inventory_key, str) or not self.inventory_key.strip(): | |
| raise ValueError("RaidAsset.inventory_key must be a non-empty string.") | |
| if isinstance(self.amount, bool) or not isinstance(self.amount, Real): | |
| raise TypeError("RaidAsset.amount must be numeric.") | |
| amount = float(self.amount) | |
| if not np.isfinite(amount): | |
| raise ValueError("RaidAsset.amount must be finite.") | |
| if amount < 0.0: | |
| raise ValueError("RaidAsset.amount cannot be negative.") | |
| if not isinstance(self.metadata, Mapping): | |
| raise TypeError("RaidAsset.metadata must be a mapping.") | |
| object.__setattr__(self, "key", self.key.strip()) | |
| object.__setattr__(self, "amount", amount) | |
| object.__setattr__(self, "inventory_key", self.inventory_key.strip()) | |
| object.__setattr__(self, "metadata", deepcopy(dict(self.metadata))) | |
| def from_mapping( | |
| cls, | |
| raw_asset: Mapping[str, Any], | |
| *, | |
| default_container: AssetContainer = "inventory", | |
| default_inventory_key: str = DEFAULT_INVENTORY_KEY, | |
| ) -> RaidAsset: | |
| """Build a RaidAsset from a DSL-style mapping.""" | |
| if not isinstance(raw_asset, Mapping): | |
| raise TypeError("Raid asset specification must be a mapping.") | |
| key = ( | |
| raw_asset.get("key") | |
| or raw_asset.get("item_key") | |
| or raw_asset.get("asset_key") | |
| or raw_asset.get("state_key") | |
| ) | |
| if key is None: | |
| raise KeyError("Raid asset requires 'key'.") | |
| amount = ( | |
| raw_asset.get("amount") | |
| if "amount" in raw_asset | |
| else raw_asset.get("quantity", raw_asset.get("value")) | |
| ) | |
| if amount is None: | |
| raise KeyError("Raid asset requires 'amount' or 'quantity'.") | |
| container = cls._normalize_container( | |
| raw_asset.get( | |
| "container", | |
| raw_asset.get("source", raw_asset.get("storage", default_container)), | |
| ) | |
| ) | |
| inventory_key = str( | |
| raw_asset.get( | |
| "inventory_key", | |
| raw_asset.get("storage_key", default_inventory_key), | |
| ) | |
| ) | |
| metadata = raw_asset.get("metadata", {}) | |
| if metadata is None: | |
| metadata = {} | |
| return cls( | |
| key=str(key), | |
| amount=amount, | |
| container=container, | |
| inventory_key=inventory_key, | |
| metadata=dict(metadata), | |
| ) | |
| def scaled(self, factor: float) -> RaidAsset: | |
| """Return a copy with amount scaled by factor.""" | |
| if isinstance(factor, bool) or not isinstance(factor, Real): | |
| raise TypeError("factor must be numeric.") | |
| normalized_factor = float(factor) | |
| if not np.isfinite(normalized_factor): | |
| raise ValueError("factor must be finite.") | |
| if normalized_factor < 0.0: | |
| raise ValueError("factor cannot be negative.") | |
| return RaidAsset( | |
| key=self.key, | |
| amount=self.amount * normalized_factor, | |
| container=self.container, | |
| inventory_key=self.inventory_key, | |
| metadata=deepcopy(dict(self.metadata)), | |
| ) | |
| def to_metadata(self) -> dict[str, Any]: | |
| """Convert the asset into JSON-friendly metadata.""" | |
| return { | |
| "key": self.key, | |
| "amount": self.amount, | |
| "container": self.container, | |
| "inventory_key": self.inventory_key, | |
| "metadata": deepcopy(dict(self.metadata)), | |
| } | |
| def _normalize_container(value: Any) -> AssetContainer: | |
| """Normalize an asset container value.""" | |
| normalized = str(value).strip().lower() | |
| if normalized in {"state", "agent_state"}: | |
| return "state" | |
| if normalized in {"inventory", "inv"}: | |
| return "inventory" | |
| raise ValueError("Asset container must be 'state' or 'inventory'.") | |
| class DamageComputation: | |
| """Structured description of a damage or pressure effect.""" | |
| target_state_key: str | None | |
| operation: str | |
| base_amount: float | |
| raw_amount: float | |
| prevented_amount: float | |
| effective_amount: float | |
| previous_value: float | None | |
| new_value: float | None | |
| explicit_updates: Mapping[str, Any] = field(default_factory=dict) | |
| marked_dead: bool = False | |
| hit: bool = True | |
| def __post_init__(self) -> None: | |
| """Validate and normalize damage computation fields.""" | |
| for label, value in { | |
| "base_amount": self.base_amount, | |
| "raw_amount": self.raw_amount, | |
| "prevented_amount": self.prevented_amount, | |
| "effective_amount": self.effective_amount, | |
| }.items(): | |
| if not np.isfinite(value): | |
| raise ValueError(f"{label} must be finite.") | |
| if self.previous_value is not None and not np.isfinite(self.previous_value): | |
| raise ValueError("previous_value must be finite when provided.") | |
| if self.new_value is not None and not np.isfinite(self.new_value): | |
| raise ValueError("new_value must be finite when provided.") | |
| if not isinstance(self.explicit_updates, Mapping): | |
| raise TypeError("explicit_updates must be a mapping.") | |
| if not isinstance(self.marked_dead, bool): | |
| raise TypeError("marked_dead must be a boolean.") | |
| if not isinstance(self.hit, bool): | |
| raise TypeError("hit must be a boolean.") | |
| object.__setattr__(self, "base_amount", float(self.base_amount)) | |
| object.__setattr__(self, "raw_amount", float(self.raw_amount)) | |
| object.__setattr__(self, "prevented_amount", float(self.prevented_amount)) | |
| object.__setattr__(self, "effective_amount", float(self.effective_amount)) | |
| object.__setattr__( | |
| self, | |
| "previous_value", | |
| None if self.previous_value is None else float(self.previous_value), | |
| ) | |
| object.__setattr__( | |
| self, | |
| "new_value", | |
| None if self.new_value is None else float(self.new_value), | |
| ) | |
| object.__setattr__(self, "explicit_updates", deepcopy(dict(self.explicit_updates))) | |
| def to_metadata(self) -> dict[str, Any]: | |
| """Convert the computation into JSON-friendly metadata.""" | |
| return { | |
| "target_state_key": self.target_state_key, | |
| "operation": self.operation, | |
| "base_amount": self.base_amount, | |
| "raw_amount": self.raw_amount, | |
| "prevented_amount": self.prevented_amount, | |
| "effective_amount": self.effective_amount, | |
| "previous_value": self.previous_value, | |
| "new_value": self.new_value, | |
| "explicit_updates": deepcopy(dict(self.explicit_updates)), | |
| "marked_dead": self.marked_dead, | |
| "hit": self.hit, | |
| } | |
| class ConflictComputation: | |
| """Structured summary of a conflict behavior execution.""" | |
| conflict_kind: str | |
| actor_id: str | |
| actor_type: str | |
| target_id: str | None = None | |
| target_type: str | None = None | |
| target: ConflictTargetCandidate | None = None | |
| actor_state_updates: Mapping[str, Any] = field(default_factory=dict) | |
| target_state_updates: Mapping[str, Any] = field(default_factory=dict) | |
| damage: DamageComputation | None = None | |
| transferred_assets: tuple[RaidAsset, ...] = field(default_factory=tuple) | |
| partial: bool = False | |
| metadata: Mapping[str, Any] = field(default_factory=dict) | |
| def __post_init__(self) -> None: | |
| """Validate and normalize conflict computation fields.""" | |
| if not isinstance(self.conflict_kind, str) or not self.conflict_kind.strip(): | |
| raise ValueError("conflict_kind must be a non-empty string.") | |
| if not isinstance(self.actor_id, str) or not self.actor_id.strip(): | |
| raise ValueError("actor_id must be a non-empty string.") | |
| if not isinstance(self.actor_type, str) or not self.actor_type.strip(): | |
| raise ValueError("actor_type must be a non-empty string.") | |
| if self.target is not None and not isinstance(self.target, ConflictTargetCandidate): | |
| raise TypeError("target must be a ConflictTargetCandidate or None.") | |
| if not isinstance(self.actor_state_updates, Mapping): | |
| raise TypeError("actor_state_updates must be a mapping.") | |
| if not isinstance(self.target_state_updates, Mapping): | |
| raise TypeError("target_state_updates must be a mapping.") | |
| if self.damage is not None and not isinstance(self.damage, DamageComputation): | |
| raise TypeError("damage must be a DamageComputation or None.") | |
| if not isinstance(self.transferred_assets, tuple): | |
| object.__setattr__(self, "transferred_assets", tuple(self.transferred_assets)) | |
| for asset in self.transferred_assets: | |
| if not isinstance(asset, RaidAsset): | |
| raise TypeError("transferred_assets must contain RaidAsset objects.") | |
| if not isinstance(self.partial, bool): | |
| raise TypeError("partial must be a boolean.") | |
| if not isinstance(self.metadata, Mapping): | |
| raise TypeError("metadata must be a mapping.") | |
| object.__setattr__(self, "conflict_kind", self.conflict_kind.strip()) | |
| object.__setattr__(self, "actor_id", self.actor_id.strip()) | |
| object.__setattr__(self, "actor_type", self.actor_type.strip()) | |
| object.__setattr__(self, "actor_state_updates", deepcopy(dict(self.actor_state_updates))) | |
| object.__setattr__(self, "target_state_updates", deepcopy(dict(self.target_state_updates))) | |
| object.__setattr__(self, "metadata", deepcopy(dict(self.metadata))) | |
| def to_metadata(self) -> dict[str, Any]: | |
| """Convert the computation into JSON-friendly metadata.""" | |
| return { | |
| "conflict_kind": self.conflict_kind, | |
| "actor_id": self.actor_id, | |
| "actor_type": self.actor_type, | |
| "target_id": self.target_id, | |
| "target_type": self.target_type, | |
| "target": None if self.target is None else self.target.to_metadata(), | |
| "actor_state_updates": deepcopy(dict(self.actor_state_updates)), | |
| "target_state_updates": deepcopy(dict(self.target_state_updates)), | |
| "damage": None if self.damage is None else self.damage.to_metadata(), | |
| "transferred_assets": [ | |
| asset.to_metadata() for asset in self.transferred_assets | |
| ], | |
| "partial": self.partial, | |
| "metadata": deepcopy(dict(self.metadata)), | |
| } | |
| class ConflictBehavior(Behavior): | |
| """Base class for generic conflict behaviors. | |
| Direct fields are mirrored into ``self.parameters`` in ``__post_init__`` so | |
| model-generated shorthand such as ``{"target": "deer", "effort": 1.0}`` | |
| works with the same rich parameter system as formal DSL specs. | |
| """ | |
| target: str | None = None | |
| target_id: str | None = None | |
| target_agent_id: str | None = None | |
| victim_id: str | None = None | |
| target_type: str | None = None | |
| victim_type: str | None = None | |
| target_selector: Mapping[str, Any] | None = None | |
| selector: Mapping[str, Any] | None = None | |
| radius: float | None = None | |
| range: float | None = None | |
| interaction_radius: float | None = None | |
| attack_radius: float | None = None | |
| selection_strategy: str | None = None | |
| target_strategy: str | None = None | |
| selection_state_key: str | None = None | |
| amount: float | None = None | |
| damage: float | None = None | |
| damage_amount: float | None = None | |
| attack_amount: float | None = None | |
| power: float | None = None | |
| effect_amount: float | None = None | |
| effort: float | None = None | |
| target_state_key: str | None = None | |
| damage_state_key: str | None = None | |
| victim_state_key: str | None = None | |
| state_key: str | None = None | |
| target_state_operation: str | None = None | |
| target_state_updates: Mapping[str, Any] | None = None | |
| victim_state_updates: Mapping[str, Any] | None = None | |
| target_state_default: float | None = None | |
| min_target_state_value: float | None = None | |
| target_state_min: float | None = None | |
| max_target_state_value: float | None = None | |
| target_state_max: float | None = None | |
| mark_dead_on_threshold: bool | None = None | |
| defeat_threshold: float | None = None | |
| dead_threshold: float | None = None | |
| death_threshold: float | None = None | |
| actor_power_state_key: str | None = None | |
| power_state_key: str | None = None | |
| attack_power_state_key: str | None = None | |
| damage_multiplier: float | None = None | |
| effect_multiplier: float | None = None | |
| amount_multiplier: float | None = None | |
| actor_power_multiplier: float | None = None | |
| power_multiplier: float | None = None | |
| target_defense_state_key: str | None = None | |
| defense_state_key: str | None = None | |
| resistance_state_key: str | None = None | |
| defense_multiplier: float | None = None | |
| resistance_multiplier: float | None = None | |
| hit_probability: float | None = None | |
| success_probability: float | None = None | |
| probability: float | None = None | |
| cost_state_key: str | None = None | |
| state_cost_key: str | None = None | |
| budget_state_key: str | None = None | |
| cost: float | None = None | |
| action_cost: float | None = None | |
| base_cost: float | None = None | |
| cost_per_unit: float | None = None | |
| cost_per_amount: float | None = None | |
| cost_per_damage: float | None = None | |
| success_reward: float | None = None | |
| reward: float | None = None | |
| reward_per_unit: float | None = None | |
| reward_per_damage: float | None = None | |
| reward_per_amount: float | None = None | |
| reward_per_fraction: float | None = None | |
| partial_reward_multiplier: float | None = None | |
| failure_reward: float | None = None | |
| failure_penalty: float | None = None | |
| defense_amount: float | None = None | |
| shield_amount: float | None = None | |
| value: float | None = None | |
| target_self: bool | None = None | |
| mode: str | None = None | |
| output_state_key: str | None = None | |
| protection_state_key: str | None = None | |
| min_state_value: float | None = None | |
| defense_state_min: float | None = None | |
| max_state_value: float | None = None | |
| defense_state_max: float | None = None | |
| raid_assets: Any = None | |
| assets: Any = None | |
| loot: Any = None | |
| steal: Any = None | |
| item_key: str | None = None | |
| quantity: float | None = None | |
| item_container: str | None = None | |
| item_inventory_key: str | None = None | |
| inventory_key: str | None = None | |
| storage_key: str | None = None | |
| allow_partial: bool | None = None | |
| minimum_raid_fraction: float | None = None | |
| min_raid_fraction: float | None = None | |
| minimum_trade_fraction: float | None = None | |
| require_target_alive: bool | None = None | |
| def __post_init__(self) -> None: | |
| """Normalize base behavior state and mirror direct fields to parameters.""" | |
| super_post = getattr(super(), "__post_init__", None) | |
| if callable(super_post): | |
| super_post() | |
| raw_parameters = getattr(self, "parameters", {}) or {} | |
| if not isinstance(raw_parameters, Mapping): | |
| raise TypeError("ConflictBehavior.parameters must be a mapping.") | |
| parameters = dict(raw_parameters) | |
| for field_info in dataclass_fields(self): | |
| field_name = field_info.name | |
| if field_name in _BEHAVIOR_BASE_FIELDS: | |
| continue | |
| value = getattr(self, field_name, None) | |
| if value is None: | |
| continue | |
| parameters.setdefault(field_name, deepcopy(value)) | |
| self._set_parameters(parameters) | |
| def _set_parameters(self, parameters: Mapping[str, Any]) -> None: | |
| """Set parameters even when parent classes use slots.""" | |
| try: | |
| self.parameters = dict(parameters) | |
| except Exception: | |
| object.__setattr__(self, "parameters", dict(parameters)) | |
| def _check_preconditions(self, agent: Agent, world: WorldProtocol) -> bool: | |
| """Check generic target and budget preconditions.""" | |
| try: | |
| target = self._select_target(agent=agent, world=world) | |
| if target is None: | |
| return False | |
| return self._has_sufficient_action_budget( | |
| agent=agent, | |
| intensity=self._attack_amount(default=DEFAULT_ATTACK_AMOUNT), | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Conflict precondition failed for agent_id=%s behavior_id=%s", | |
| agent.id, | |
| getattr(self, "id", self.__class__.__name__), | |
| ) | |
| return False | |
| def _select_target( | |
| self, | |
| *, | |
| agent: Agent, | |
| world: WorldProtocol, | |
| ) -> tuple[Agent, ConflictTargetCandidate] | None: | |
| """Select a target agent.""" | |
| candidates = self._ordered_target_candidates(agent=agent, world=world) | |
| if not candidates: | |
| return None | |
| return candidates[0] | |
| def _ordered_target_candidates( | |
| self, | |
| *, | |
| agent: Agent, | |
| world: WorldProtocol, | |
| ) -> list[tuple[Agent, ConflictTargetCandidate]]: | |
| """Return target candidates ordered by configured strategy.""" | |
| selector = self._target_selector() | |
| radius = self._radius() | |
| require_alive = self._optional_bool_parameter( | |
| "require_target_alive", | |
| default=True, | |
| ) | |
| agent_position = self._agent_position(agent) | |
| candidates: list[tuple[Agent, ConflictTargetCandidate]] = [] | |
| for other in self._agents_from_world(world=world, selector=selector): | |
| if not isinstance(other, Agent): | |
| continue | |
| if other.id == agent.id: | |
| continue | |
| if require_alive and not other.alive: | |
| continue | |
| if not self._matches_agent_selector(other, selector): | |
| continue | |
| other_position = self._agent_position(other) | |
| if other_position.shape != agent_position.shape: | |
| continue | |
| distance = float(np.linalg.norm(other_position - agent_position)) | |
| if radius is not None and distance > radius: | |
| continue | |
| candidates.append( | |
| ( | |
| other, | |
| ConflictTargetCandidate( | |
| id=other.id, | |
| type=other.type, | |
| position=other_position.copy(), | |
| distance=distance, | |
| metadata={"class": other.__class__.__name__}, | |
| ), | |
| ) | |
| ) | |
| strategy = self._selection_strategy() | |
| if strategy == "nearest": | |
| candidates.sort(key=lambda item: (item[1].distance, item[1].id)) | |
| elif strategy == "farthest": | |
| candidates.sort(key=lambda item: (-item[1].distance, item[1].id)) | |
| elif strategy == "random": | |
| rng = self._rng(world) | |
| if len(candidates) > 1: | |
| indices = rng.permutation(len(candidates)) | |
| candidates = [candidates[int(index)] for index in indices] | |
| elif strategy in {"highest_state", "lowest_state"}: | |
| state_key = self._required_string_parameter( | |
| "selection_state_key", | |
| aliases=("target_state_rank_key", "rank_state_key"), | |
| ) | |
| reverse = strategy == "highest_state" | |
| candidates.sort( | |
| key=lambda item: ( | |
| -self._numeric_state(item[0], state_key, default=0.0) | |
| if reverse | |
| else self._numeric_state(item[0], state_key, default=0.0), | |
| item[1].distance, | |
| item[1].id, | |
| ) | |
| ) | |
| else: | |
| raise ValueError(f"Unsupported target selection strategy: {strategy}") | |
| return candidates | |
| def _agents_from_world( | |
| self, | |
| *, | |
| world: WorldProtocol, | |
| selector: Mapping[str, Any], | |
| ) -> list[Any]: | |
| """Retrieve candidate agents from the world.""" | |
| selector_method = getattr(world, "select_agents", None) | |
| if callable(selector_method): | |
| try: | |
| selected = selector_method(selector) | |
| except TypeError: | |
| selected = selector_method(selector=selector) | |
| if isinstance(selected, Mapping): | |
| return list(selected.values()) | |
| if isinstance(selected, Sequence) and not isinstance(selected, (str, bytes)): | |
| return list(selected) | |
| return [] | |
| agents = getattr(world, "agents", {}) | |
| if isinstance(agents, Mapping): | |
| return list(agents.values()) | |
| if isinstance(agents, Sequence) and not isinstance(agents, (str, bytes)): | |
| return list(agents) | |
| return [] | |
| def _matches_agent_selector( | |
| self, | |
| candidate: Agent, | |
| selector: Mapping[str, Any], | |
| ) -> bool: | |
| """Return whether an agent matches a selector.""" | |
| if "id" in selector and not self._value_matches(candidate.id, selector["id"]): | |
| return False | |
| if "ids" in selector and not self._value_matches(candidate.id, selector["ids"]): | |
| return False | |
| if "type" in selector and not self._value_matches(candidate.type, selector["type"]): | |
| return False | |
| if "types" in selector and not self._value_matches(candidate.type, selector["types"]): | |
| return False | |
| if "alive" in selector and candidate.alive is not bool(selector["alive"]): | |
| return False | |
| if "state" in selector and not self._mapping_contains( | |
| candidate.state, | |
| selector["state"], | |
| ): | |
| return False | |
| if "state_min" in selector and not self._matches_numeric_thresholds( | |
| candidate.state, | |
| selector["state_min"], | |
| comparison="min", | |
| ): | |
| return False | |
| if "state_max" in selector and not self._matches_numeric_thresholds( | |
| candidate.state, | |
| selector["state_max"], | |
| comparison="max", | |
| ): | |
| return False | |
| return True | |
| def _compute_damage_updates( | |
| self, | |
| *, | |
| actor: Agent, | |
| target: Agent, | |
| base_amount: float, | |
| hit: bool, | |
| ) -> tuple[dict[str, Any], DamageComputation]: | |
| """Compute target state updates for an attack-like effect.""" | |
| target_state_key = self._target_state_key() | |
| explicit_updates = self._target_state_updates() | |
| normalized_base = self._normalize_non_negative_float( | |
| base_amount, | |
| "base_amount", | |
| ) | |
| if not hit: | |
| return {}, DamageComputation( | |
| target_state_key=target_state_key, | |
| operation=self._target_state_operation(), | |
| base_amount=normalized_base, | |
| raw_amount=0.0, | |
| prevented_amount=0.0, | |
| effective_amount=0.0, | |
| previous_value=None, | |
| new_value=None, | |
| explicit_updates={}, | |
| marked_dead=False, | |
| hit=False, | |
| ) | |
| raw_amount = self._raw_effect_amount( | |
| actor=actor, | |
| target=target, | |
| base_amount=normalized_base, | |
| ) | |
| prevented_amount = self._prevented_amount(target=target, raw_amount=raw_amount) | |
| effective_amount = max(0.0, raw_amount - prevented_amount) | |
| updates: dict[str, Any] = deepcopy(dict(explicit_updates)) | |
| previous_value: float | None = None | |
| new_value: float | None = None | |
| marked_dead = False | |
| operation = self._target_state_operation() | |
| if target_state_key is not None: | |
| previous_value = self._numeric_state_required_or_default( | |
| target, | |
| target_state_key, | |
| default_parameter="target_state_default", | |
| ) | |
| if operation == "decrement": | |
| new_value = previous_value - effective_amount | |
| elif operation == "increment": | |
| new_value = previous_value + effective_amount | |
| elif operation == "set": | |
| new_value = effective_amount | |
| elif operation == "multiply": | |
| new_value = previous_value * effective_amount | |
| else: | |
| raise ValueError( | |
| "target_state_operation must be one of: decrement, " | |
| "increment, set, multiply." | |
| ) | |
| new_value = self._apply_target_state_clamps(new_value) | |
| updates[target_state_key] = new_value | |
| if self._optional_bool_parameter("mark_dead_on_threshold", default=False): | |
| threshold = self._optional_finite_float_parameter( | |
| "defeat_threshold", | |
| aliases=("dead_threshold", "death_threshold"), | |
| default=0.0, | |
| ) | |
| assert threshold is not None | |
| marked_dead = new_value <= threshold | |
| if not updates: | |
| raise ValueError( | |
| "Conflict effect has no configured target mutation. Provide " | |
| "'target_state_key' or 'target_state_updates'." | |
| ) | |
| computation = DamageComputation( | |
| target_state_key=target_state_key, | |
| operation=operation, | |
| base_amount=normalized_base, | |
| raw_amount=raw_amount, | |
| prevented_amount=prevented_amount, | |
| effective_amount=effective_amount, | |
| previous_value=previous_value, | |
| new_value=new_value, | |
| explicit_updates=explicit_updates, | |
| marked_dead=marked_dead, | |
| hit=True, | |
| ) | |
| return updates, computation | |
| def _raw_effect_amount( | |
| self, | |
| *, | |
| actor: Agent, | |
| target: Agent, | |
| base_amount: float, | |
| ) -> float: | |
| """Compute raw effect amount before mitigation.""" | |
| del target | |
| multiplier = self._optional_finite_float_parameter( | |
| "damage_multiplier", | |
| aliases=("effect_multiplier", "amount_multiplier"), | |
| default=1.0, | |
| ) | |
| assert multiplier is not None | |
| effort = self._optional_non_negative_float_parameter( | |
| "effort", | |
| default=1.0, | |
| ) | |
| assert effort is not None | |
| raw_amount = base_amount * multiplier * effort | |
| actor_power_key = self._optional_string_parameter( | |
| "actor_power_state_key", | |
| aliases=("power_state_key", "attack_power_state_key"), | |
| default=None, | |
| ) | |
| if actor_power_key is not None: | |
| actor_power = self._numeric_state(actor, actor_power_key, default=0.0) | |
| actor_power_multiplier = self._optional_finite_float_parameter( | |
| "actor_power_multiplier", | |
| aliases=("power_multiplier",), | |
| default=1.0, | |
| ) | |
| assert actor_power_multiplier is not None | |
| raw_amount += actor_power * actor_power_multiplier | |
| if raw_amount < 0.0: | |
| return 0.0 | |
| return raw_amount | |
| def _prevented_amount(self, *, target: Agent, raw_amount: float) -> float: | |
| """Compute amount prevented by target defensive state.""" | |
| defense_key = self._optional_string_parameter( | |
| "target_defense_state_key", | |
| aliases=("defense_state_key", "resistance_state_key"), | |
| default=None, | |
| ) | |
| if defense_key is None: | |
| return 0.0 | |
| defense_value = self._numeric_state(target, defense_key, default=0.0) | |
| defense_multiplier = self._optional_finite_float_parameter( | |
| "defense_multiplier", | |
| aliases=("resistance_multiplier",), | |
| default=1.0, | |
| ) | |
| assert defense_multiplier is not None | |
| prevented = defense_value * defense_multiplier | |
| if prevented <= 0.0: | |
| return 0.0 | |
| return min(raw_amount, prevented) | |
| def _build_cost_state_updates( | |
| self, | |
| *, | |
| agent: Agent, | |
| intensity: float, | |
| existing_updates: Mapping[str, Any], | |
| charge: bool = True, | |
| ) -> dict[str, Any]: | |
| """Build actor state updates for optional action cost.""" | |
| if not charge: | |
| return {} | |
| key = self._cost_state_key() | |
| if key is None: | |
| return {} | |
| cost = self._action_cost(intensity) | |
| if cost <= ZERO_TOLERANCE: | |
| return {} | |
| if key in existing_updates: | |
| value = existing_updates[key] | |
| if isinstance(value, bool) or not isinstance(value, Real): | |
| raise TypeError( | |
| f"Cannot charge action cost against non-numeric pending key '{key}'." | |
| ) | |
| current = float(value) | |
| else: | |
| current = self._numeric_state(agent, key, default=0.0) | |
| return {key: current - cost} | |
| def _has_sufficient_action_budget(self, *, agent: Agent, intensity: float) -> bool: | |
| """Return whether the agent can pay optional action cost.""" | |
| key = self._cost_state_key() | |
| if key is None: | |
| return True | |
| cost = self._action_cost(intensity) | |
| if cost <= ZERO_TOLERANCE: | |
| return True | |
| current = self._numeric_state(agent, key, default=0.0) | |
| return current >= cost | |
| def _action_cost(self, intensity: float) -> float: | |
| """Compute optional action cost.""" | |
| normalized_intensity = self._normalize_non_negative_float(intensity, "intensity") | |
| base_cost = self._optional_non_negative_float_parameter( | |
| "cost", | |
| aliases=("action_cost", "base_cost"), | |
| default=0.0, | |
| ) | |
| cost_per_unit = self._optional_non_negative_float_parameter( | |
| "cost_per_unit", | |
| aliases=("cost_per_amount", "cost_per_damage"), | |
| default=0.0, | |
| ) | |
| assert base_cost is not None | |
| assert cost_per_unit is not None | |
| return base_cost + cost_per_unit * normalized_intensity | |
| def _roll_hit(self, world: WorldProtocol) -> bool: | |
| """Resolve probabilistic hit success.""" | |
| probability = self._optional_non_negative_float_parameter( | |
| "hit_probability", | |
| aliases=("success_probability", "probability"), | |
| default=1.0, | |
| ) | |
| assert probability is not None | |
| if probability > 1.0: | |
| raise ValueError("hit_probability cannot exceed 1.0.") | |
| if probability >= 1.0: | |
| return True | |
| if probability <= 0.0: | |
| return False | |
| return bool(self._rng(world).random() < probability) | |
| def _target_selector(self) -> dict[str, Any]: | |
| """Return normalized target selector. | |
| Supports formal style: | |
| {"target_selector": {"type": "deer"}} | |
| and model-friendly shorthand: | |
| {"target": "deer"} | |
| """ | |
| selector = self.parameters.get( | |
| "target_selector", | |
| self.parameters.get("selector", {}), | |
| ) | |
| if selector is None: | |
| selector = {} | |
| if not isinstance(selector, Mapping): | |
| raise TypeError("target_selector must be a mapping.") | |
| normalized = deepcopy(dict(selector)) | |
| self._validate_string_keys(normalized, "target_selector") | |
| for parameter_key, selector_key in ( | |
| ("target_id", "id"), | |
| ("target_agent_id", "id"), | |
| ("victim_id", "id"), | |
| ("target_type", "type"), | |
| ("victim_type", "type"), | |
| ): | |
| if parameter_key in self.parameters and self.parameters[parameter_key] is not None: | |
| normalized.setdefault(selector_key, self.parameters[parameter_key]) | |
| raw_target = self.parameters.get("target") | |
| if isinstance(raw_target, str): | |
| target_text = raw_target.strip() | |
| if target_text and target_text.lower() not in { | |
| "agent", | |
| "agents", | |
| "resource", | |
| "resources", | |
| "target", | |
| "victim", | |
| }: | |
| normalized.setdefault("type", target_text) | |
| return normalized | |
| def _radius(self) -> float | None: | |
| """Return optional target interaction radius.""" | |
| return self._optional_non_negative_float_parameter( | |
| "radius", | |
| aliases=("range", "interaction_radius", "attack_radius"), | |
| default=None, | |
| ) | |
| def _selection_strategy(self) -> TargetSelectionStrategy: | |
| """Return target selection strategy.""" | |
| raw_value = self.parameters.get( | |
| "selection_strategy", | |
| self.parameters.get("target_strategy", "nearest"), | |
| ) | |
| value = str(raw_value).strip().lower() | |
| if value in {"nearest", "closest"}: | |
| return "nearest" | |
| if value in {"farthest", "furthest"}: | |
| return "farthest" | |
| if value == "random": | |
| return "random" | |
| if value in {"highest_state", "max_state"}: | |
| return "highest_state" | |
| if value in {"lowest_state", "min_state"}: | |
| return "lowest_state" | |
| raise ValueError( | |
| "selection_strategy must be one of: nearest, farthest, random, " | |
| "highest_state, lowest_state." | |
| ) | |
| def _attack_amount(self, *, default: float) -> float: | |
| """Return configured attack or pressure amount.""" | |
| for key in ( | |
| "amount", | |
| "damage", | |
| "damage_amount", | |
| "attack_amount", | |
| "power", | |
| "effect_amount", | |
| ): | |
| if key in self.parameters and self.parameters[key] is not None: | |
| return self._normalize_non_negative_float(self.parameters[key], key) | |
| return default | |
| def _target_state_key(self) -> str | None: | |
| """Return optional target state key affected by attack. | |
| For model-friendly ``attack`` / ``hunt`` behaviors, this defaults to | |
| ``health`` when the DSL does not provide an explicit target state key. | |
| """ | |
| explicit = self._optional_string_parameter( | |
| "target_state_key", | |
| aliases=("damage_state_key", "victim_state_key", "state_key"), | |
| default=None, | |
| ) | |
| if explicit is not None: | |
| return explicit | |
| return str(self.parameters.get("default_target_state_key", "health")) | |
| def _target_state_operation(self) -> str: | |
| """Return operation used for target state mutation.""" | |
| operation = str( | |
| self.parameters.get("target_state_operation", "decrement") | |
| ).strip().lower() | |
| if operation in {"subtract", "reduce", "damage"}: | |
| return "decrement" | |
| if operation in {"add", "increase"}: | |
| return "increment" | |
| if operation in {"assign"}: | |
| return "set" | |
| if operation in {"scale"}: | |
| return "multiply" | |
| return operation | |
| def _target_state_updates(self) -> dict[str, Any]: | |
| """Return explicit target state updates.""" | |
| updates = self.parameters.get("target_state_updates") | |
| if updates is None: | |
| updates = self.parameters.get("victim_state_updates") | |
| if updates is None: | |
| return {} | |
| if not isinstance(updates, Mapping): | |
| raise TypeError("target_state_updates must be a mapping.") | |
| self._validate_string_keys(updates, "target_state_updates") | |
| return deepcopy(dict(updates)) | |
| def _apply_target_state_clamps(self, value: float) -> float: | |
| """Apply optional target state clamps.""" | |
| new_value = value | |
| min_value = self._optional_finite_float_parameter( | |
| "min_target_state_value", | |
| aliases=("target_state_min",), | |
| default=0.0, | |
| ) | |
| max_value = self._optional_finite_float_parameter( | |
| "max_target_state_value", | |
| aliases=("target_state_max",), | |
| default=None, | |
| ) | |
| if min_value is not None: | |
| new_value = max(new_value, min_value) | |
| if max_value is not None: | |
| new_value = min(new_value, max_value) | |
| return new_value | |
| def _cost_state_key(self) -> str | None: | |
| """Return optional actor state key used to pay action cost.""" | |
| return self._optional_string_parameter( | |
| "cost_state_key", | |
| aliases=("state_cost_key", "budget_state_key"), | |
| default=None, | |
| ) | |
| def _success_reward(self, *, intensity: float = 0.0, fraction: float = 1.0) -> float: | |
| """Compute success reward.""" | |
| base_reward = self._optional_finite_float_parameter( | |
| "success_reward", | |
| aliases=("reward",), | |
| default=0.0, | |
| ) | |
| reward_per_unit = self._optional_finite_float_parameter( | |
| "reward_per_unit", | |
| aliases=("reward_per_damage", "reward_per_amount"), | |
| default=0.0, | |
| ) | |
| reward_per_fraction = self._optional_finite_float_parameter( | |
| "reward_per_fraction", | |
| aliases=("partial_reward_multiplier",), | |
| default=0.0, | |
| ) | |
| assert base_reward is not None | |
| assert reward_per_unit is not None | |
| assert reward_per_fraction is not None | |
| return base_reward + reward_per_unit * intensity + reward_per_fraction * fraction | |
| def _failure_reward(self) -> float: | |
| """Return failure reward or penalty.""" | |
| reward = self._optional_finite_float_parameter( | |
| "failure_reward", | |
| aliases=("failure_penalty",), | |
| default=0.0, | |
| ) | |
| assert reward is not None | |
| return reward | |
| def _inventory_key(self) -> str: | |
| """Return default inventory state key.""" | |
| value = self._optional_string_parameter( | |
| "inventory_key", | |
| aliases=("storage_key",), | |
| default=DEFAULT_INVENTORY_KEY, | |
| ) | |
| assert value is not None | |
| return value | |
| def _read_asset_amount( | |
| self, | |
| *, | |
| agent: Agent, | |
| asset: RaidAsset, | |
| pending_updates: Mapping[str, Any], | |
| ) -> float: | |
| """Read asset amount from state plus pending updates.""" | |
| if asset.container == "state": | |
| value = pending_updates.get(asset.key, agent.state.get(asset.key, 0.0)) | |
| return self._normalize_asset_amount(value, f"agent.state['{asset.key}']") | |
| inventory = self._read_inventory( | |
| agent=agent, | |
| inventory_key=asset.inventory_key, | |
| pending_updates=pending_updates, | |
| ) | |
| value = inventory.get(asset.key, 0.0) | |
| return self._normalize_asset_amount( | |
| value, | |
| f"agent.state['{asset.inventory_key}']['{asset.key}']", | |
| ) | |
| def _write_asset_amount( | |
| self, | |
| *, | |
| agent: Agent, | |
| pending_updates: dict[str, Any], | |
| asset: RaidAsset, | |
| amount: float, | |
| ) -> None: | |
| """Write asset amount to pending updates.""" | |
| normalized_amount = self._normalize_non_negative_float(amount, "amount") | |
| if normalized_amount <= ZERO_TOLERANCE: | |
| normalized_amount = 0.0 | |
| if asset.container == "state": | |
| pending_updates[asset.key] = normalized_amount | |
| return | |
| inventory = self._read_inventory( | |
| agent=agent, | |
| inventory_key=asset.inventory_key, | |
| pending_updates=pending_updates, | |
| ) | |
| updated_inventory = deepcopy(dict(inventory)) | |
| updated_inventory[asset.key] = normalized_amount | |
| pending_updates[asset.inventory_key] = updated_inventory | |
| def _add_asset( | |
| self, | |
| *, | |
| agent: Agent, | |
| pending_updates: dict[str, Any], | |
| asset: RaidAsset, | |
| amount: float, | |
| ) -> None: | |
| """Add an asset amount into pending updates.""" | |
| current = self._read_asset_amount( | |
| agent=agent, | |
| asset=asset, | |
| pending_updates=pending_updates, | |
| ) | |
| self._write_asset_amount( | |
| agent=agent, | |
| pending_updates=pending_updates, | |
| asset=asset, | |
| amount=current + amount, | |
| ) | |
| def _subtract_asset( | |
| self, | |
| *, | |
| agent: Agent, | |
| pending_updates: dict[str, Any], | |
| asset: RaidAsset, | |
| amount: float, | |
| ) -> None: | |
| """Subtract an asset amount into pending updates.""" | |
| current = self._read_asset_amount( | |
| agent=agent, | |
| asset=asset, | |
| pending_updates=pending_updates, | |
| ) | |
| new_amount = current - amount | |
| if new_amount < -ZERO_TOLERANCE: | |
| raise ValueError( | |
| f"Agent '{agent.id}' lacks sufficient asset '{asset.key}'. " | |
| f"Required {amount}, available {current}." | |
| ) | |
| self._write_asset_amount( | |
| agent=agent, | |
| pending_updates=pending_updates, | |
| asset=asset, | |
| amount=max(0.0, new_amount), | |
| ) | |
| def _read_inventory( | |
| self, | |
| *, | |
| agent: Agent, | |
| inventory_key: str, | |
| pending_updates: Mapping[str, Any], | |
| ) -> dict[str, Any]: | |
| """Read an inventory mapping from state plus pending updates.""" | |
| value = pending_updates.get(inventory_key, agent.state.get(inventory_key, {})) | |
| if value is None: | |
| return {} | |
| if not isinstance(value, Mapping): | |
| raise TypeError( | |
| f"Agent '{agent.id}' state key '{inventory_key}' must be a mapping." | |
| ) | |
| return deepcopy(dict(value)) | |
| def _available_asset_fraction( | |
| self, | |
| *, | |
| agent: Agent, | |
| assets: Sequence[RaidAsset], | |
| pending_updates: Mapping[str, Any], | |
| ) -> float: | |
| """Compute fraction of requested assets available from an agent.""" | |
| fractions: list[float] = [] | |
| for asset in assets: | |
| if asset.amount <= ZERO_TOLERANCE: | |
| continue | |
| available = self._read_asset_amount( | |
| agent=agent, | |
| asset=asset, | |
| pending_updates=pending_updates, | |
| ) | |
| fractions.append(available / asset.amount) | |
| if not fractions: | |
| return 1.0 | |
| return min(fractions) | |
| def _position_array(agent: Agent) -> NDArray[np.float64]: | |
| """Return agent position as a NumPy array, repairing tuple/list assignment.""" | |
| raw_position = getattr(agent, "position", None) | |
| if raw_position is None: | |
| position = np.zeros(2, dtype=np.float64) | |
| else: | |
| position = np.asarray(raw_position, dtype=np.float64).reshape(-1) | |
| if position.size == 0: | |
| position = np.zeros(2, dtype=np.float64) | |
| if not np.all(np.isfinite(position)): | |
| position = np.zeros(2, dtype=np.float64) | |
| if not isinstance(raw_position, np.ndarray): | |
| try: | |
| agent.set_position(position) | |
| except Exception: | |
| try: | |
| agent.position = position | |
| except Exception: | |
| logger.debug("Could not repair agent.position", exc_info=True) | |
| return position.astype(np.float64, copy=True) | |
| def _agent_position(self, agent: Agent) -> NDArray[np.float64]: | |
| """Return normalized agent position.""" | |
| return self._position_array(agent) | |
| def _numeric_state(agent: Agent, key: str, *, default: float) -> float: | |
| """Read a numeric agent state value.""" | |
| value = agent.state.get(key, default) | |
| if isinstance(value, bool) or not isinstance(value, Real): | |
| return default | |
| normalized = float(value) | |
| if not np.isfinite(normalized): | |
| return default | |
| return normalized | |
| def _numeric_state_required_or_default( | |
| self, | |
| agent: Agent, | |
| key: str, | |
| *, | |
| default_parameter: str, | |
| ) -> float: | |
| """Read required numeric state or parameter-provided default.""" | |
| if key in agent.state: | |
| value = agent.state[key] | |
| elif default_parameter in self.parameters and self.parameters[default_parameter] is not None: | |
| value = self.parameters[default_parameter] | |
| elif key == "health": | |
| value = 100.0 | |
| else: | |
| raise KeyError( | |
| f"Target agent '{agent.id}' is missing required state key '{key}'. " | |
| f"Provide '{default_parameter}' to supply a default." | |
| ) | |
| if isinstance(value, bool) or not isinstance(value, Real): | |
| raise TypeError(f"Agent '{agent.id}' state key '{key}' must be numeric.") | |
| normalized = float(value) | |
| if not np.isfinite(normalized): | |
| raise ValueError(f"Agent '{agent.id}' state key '{key}' must be finite.") | |
| return normalized | |
| def _normalize_asset_amount(value: Any, label: str) -> float: | |
| """Normalize an existing asset amount.""" | |
| if isinstance(value, bool) or not isinstance(value, Real): | |
| raise TypeError(f"{label} must be numeric.") | |
| normalized = float(value) | |
| if not np.isfinite(normalized): | |
| raise ValueError(f"{label} must be finite.") | |
| if normalized < -ZERO_TOLERANCE: | |
| raise ValueError(f"{label} cannot be negative.") | |
| return max(0.0, normalized) | |
| def _matches_numeric_thresholds( | |
| mapping: Mapping[str, Any], | |
| thresholds: Any, | |
| *, | |
| comparison: str, | |
| ) -> bool: | |
| """Return whether mapping values satisfy numeric thresholds.""" | |
| if not isinstance(thresholds, Mapping): | |
| raise TypeError("Numeric threshold selector must be a mapping.") | |
| for key, threshold in thresholds.items(): | |
| if not isinstance(key, str): | |
| raise TypeError("Numeric threshold keys must be strings.") | |
| if key not in mapping: | |
| return False | |
| value = mapping[key] | |
| if isinstance(value, bool) or not isinstance(value, Real): | |
| return False | |
| if isinstance(threshold, bool) or not isinstance(threshold, Real): | |
| raise TypeError("Numeric threshold values must be numeric.") | |
| normalized_value = float(value) | |
| normalized_threshold = float(threshold) | |
| if not np.isfinite(normalized_value) or not np.isfinite(normalized_threshold): | |
| return False | |
| if comparison == "min" and normalized_value < normalized_threshold: | |
| return False | |
| if comparison == "max" and normalized_value > normalized_threshold: | |
| return False | |
| return True | |
| def _value_matches(value: Any, expected: Any) -> bool: | |
| """Return whether a value matches a scalar or collection filter.""" | |
| if isinstance(expected, (str, bytes)): | |
| return value == expected | |
| if isinstance(expected, set): | |
| return value in expected | |
| if isinstance(expected, frozenset): | |
| return value in expected | |
| if isinstance(expected, Sequence): | |
| return value in expected | |
| return value == expected | |
| def _mapping_contains(actual: Any, expected: Any) -> bool: | |
| """Return whether actual mapping contains expected key/value pairs.""" | |
| if not isinstance(actual, Mapping) or not isinstance(expected, Mapping): | |
| return False | |
| for key, expected_value in expected.items(): | |
| if key not in actual: | |
| return False | |
| if actual[key] != expected_value: | |
| return False | |
| return True | |
| def _rng(world: WorldProtocol) -> np.random.Generator: | |
| """Return world RNG or fallback generator.""" | |
| rng = getattr(world, "rng", None) | |
| if isinstance(rng, np.random.Generator): | |
| return rng | |
| logger.debug("World does not expose a NumPy Generator as rng; using fallback RNG.") | |
| return np.random.default_rng(0) | |
| def _optional_bool_parameter(self, key: str, *, default: bool) -> bool: | |
| """Return optional boolean parameter.""" | |
| if key not in self.parameters or self.parameters[key] is None: | |
| return default | |
| value = self.parameters[key] | |
| if not isinstance(value, bool): | |
| raise TypeError(f"Parameter '{key}' must be a boolean.") | |
| return value | |
| def _optional_string_parameter( | |
| self, | |
| key: str, | |
| *, | |
| aliases: Sequence[str] = (), | |
| default: str | None, | |
| ) -> str | None: | |
| """Return optional string parameter.""" | |
| for candidate_key in (key, *aliases): | |
| if candidate_key in self.parameters and self.parameters[candidate_key] is not None: | |
| value = self.parameters[candidate_key] | |
| if not isinstance(value, str) or not value.strip(): | |
| raise ValueError( | |
| f"Parameter '{candidate_key}' must be a non-empty string." | |
| ) | |
| return value.strip() | |
| return default | |
| def _required_string_parameter( | |
| self, | |
| key: str, | |
| *, | |
| aliases: Sequence[str] = (), | |
| ) -> str: | |
| """Return required string parameter.""" | |
| value = self._optional_string_parameter( | |
| key, | |
| aliases=aliases, | |
| default=None, | |
| ) | |
| if value is None: | |
| expected = ", ".join((key, *aliases)) | |
| raise KeyError(f"Missing required string parameter. Expected one of: {expected}") | |
| return value | |
| def _optional_finite_float_parameter( | |
| self, | |
| key: str, | |
| *, | |
| aliases: Sequence[str] = (), | |
| default: float | None, | |
| ) -> float | None: | |
| """Return optional finite float parameter.""" | |
| for candidate_key in (key, *aliases): | |
| if candidate_key in self.parameters and self.parameters[candidate_key] is not None: | |
| return self._normalize_finite_float( | |
| self.parameters[candidate_key], | |
| candidate_key, | |
| ) | |
| return default | |
| def _optional_non_negative_float_parameter( | |
| self, | |
| key: str, | |
| *, | |
| aliases: Sequence[str] = (), | |
| default: float | None, | |
| ) -> float | None: | |
| """Return optional non-negative float parameter.""" | |
| value = self._optional_finite_float_parameter( | |
| key, | |
| aliases=aliases, | |
| default=default, | |
| ) | |
| if value is not None and value < 0.0: | |
| raise ValueError(f"Parameter '{key}' cannot be negative.") | |
| return value | |
| def _normalize_finite_float(value: Any, label: str) -> float: | |
| """Normalize a finite float.""" | |
| if isinstance(value, bool) or not isinstance(value, Real): | |
| raise TypeError(f"{label} must be numeric.") | |
| normalized = float(value) | |
| if not np.isfinite(normalized): | |
| raise ValueError(f"{label} must be finite.") | |
| return normalized | |
| def _normalize_non_negative_float(self, value: Any, label: str) -> float: | |
| """Normalize a non-negative finite float.""" | |
| normalized = self._normalize_finite_float(value, label) | |
| if normalized < 0.0: | |
| raise ValueError(f"{label} cannot be negative.") | |
| return normalized | |
| def _validate_string_keys(mapping: Mapping[str, Any], label: str) -> None: | |
| """Validate mapping keys are strings.""" | |
| for key in mapping: | |
| if not isinstance(key, str): | |
| raise TypeError(f"{label} keys must be strings.") | |
| class AttackBehavior(ConflictBehavior): | |
| """Apply a generic attack or pressure effect to a target agent.""" | |
| def _check_preconditions(self, agent: Agent, world: WorldProtocol) -> bool: | |
| """Check attack-specific preconditions.""" | |
| try: | |
| if not self._has_attack_mutation_configured(): | |
| return False | |
| if not self._has_sufficient_action_budget( | |
| agent=agent, | |
| intensity=self._attack_amount(default=DEFAULT_ATTACK_AMOUNT), | |
| ): | |
| return False | |
| return self._select_target(agent=agent, world=world) is not None | |
| except Exception: | |
| logger.exception( | |
| "Attack precondition failed for agent_id=%s behavior_id=%s", | |
| agent.id, | |
| getattr(self, "id", self.__class__.__name__), | |
| ) | |
| return False | |
| def execute(self, agent: Agent, world: WorldProtocol) -> BehaviorResult: | |
| """Execute the attack.""" | |
| try: | |
| selected = self._select_target(agent=agent, world=world) | |
| if selected is None: | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="AttackBehavior found no matching target.", | |
| metadata={ | |
| "target_selector": self._target_selector(), | |
| "radius": self._radius(), | |
| }, | |
| ) | |
| target, candidate = selected | |
| amount = self._attack_amount(default=DEFAULT_ATTACK_AMOUNT) | |
| if not self._has_sufficient_action_budget(agent=agent, intensity=amount): | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="Agent does not have sufficient state budget to attack.", | |
| metadata={ | |
| "required_cost": self._action_cost(amount), | |
| "cost_state_key": self._cost_state_key(), | |
| }, | |
| ) | |
| actor_updates = self._build_cost_state_updates( | |
| agent=agent, | |
| intensity=amount, | |
| existing_updates={}, | |
| charge=True, | |
| ) | |
| hit = self._roll_hit(world) | |
| target_updates, damage = self._compute_damage_updates( | |
| actor=agent, | |
| target=target, | |
| base_amount=amount, | |
| hit=hit, | |
| ) | |
| if not hit: | |
| computation = ConflictComputation( | |
| conflict_kind=self.name, | |
| actor_id=agent.id, | |
| actor_type=agent.type, | |
| target_id=target.id, | |
| target_type=target.type, | |
| target=candidate, | |
| actor_state_updates=actor_updates, | |
| target_state_updates={}, | |
| damage=damage, | |
| partial=False, | |
| metadata={"hit": False}, | |
| ) | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| state_updates=actor_updates, | |
| memory_updates={"conflict": computation.to_metadata()}, | |
| message=f"{self.__class__.__name__} missed target.", | |
| metadata=computation.to_metadata(), | |
| ) | |
| target.update_state(target_updates) | |
| if damage.marked_dead: | |
| target.mark_dead( | |
| reason=f"Threshold reached by behavior '{getattr(self, 'id', self.name)}'." | |
| ) | |
| computation = ConflictComputation( | |
| conflict_kind=self.name, | |
| actor_id=agent.id, | |
| actor_type=agent.type, | |
| target_id=target.id, | |
| target_type=target.type, | |
| target=candidate, | |
| actor_state_updates=actor_updates, | |
| target_state_updates=target_updates, | |
| damage=damage, | |
| partial=False, | |
| metadata={ | |
| "hit": True, | |
| "target_alive_after": target.alive, | |
| }, | |
| ) | |
| metadata = computation.to_metadata() | |
| return self.success( | |
| reward=self._success_reward(intensity=damage.effective_amount), | |
| state_updates=actor_updates, | |
| memory_updates={"conflict": metadata}, | |
| message=f"Agent performed {self.name}.", | |
| metadata=metadata, | |
| ) | |
| except Exception as exc: | |
| logger.exception( | |
| "%s failed for agent_id=%s behavior_id=%s", | |
| self.__class__.__name__, | |
| agent.id, | |
| getattr(self, "id", self.__class__.__name__), | |
| ) | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message=f"{self.__class__.__name__} raised an exception.", | |
| metadata={"error": repr(exc)}, | |
| ) | |
| def _has_attack_mutation_configured(self) -> bool: | |
| """Return whether the attack has a configured target mutation.""" | |
| return self._target_state_key() is not None or bool(self._target_state_updates()) | |
| class HuntBehavior(AttackBehavior): | |
| """Compatibility alias for model-generated hunting behavior.""" | |
| class DefendBehavior(ConflictBehavior): | |
| """Increase or set a defensive state value.""" | |
| def _check_preconditions(self, agent: Agent, world: WorldProtocol) -> bool: | |
| """Check defend-specific preconditions.""" | |
| try: | |
| _ = self._defense_state_key() | |
| amount = self._defense_amount() | |
| if not self._has_sufficient_action_budget(agent=agent, intensity=amount): | |
| return False | |
| if self._target_self(): | |
| return True | |
| return self._select_target(agent=agent, world=world) is not None | |
| except Exception: | |
| logger.exception( | |
| "Defend precondition failed for agent_id=%s behavior_id=%s", | |
| agent.id, | |
| getattr(self, "id", self.__class__.__name__), | |
| ) | |
| return False | |
| def execute(self, agent: Agent, world: WorldProtocol) -> BehaviorResult: | |
| """Execute defense behavior.""" | |
| try: | |
| amount = self._defense_amount() | |
| if not self._has_sufficient_action_budget(agent=agent, intensity=amount): | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="Agent does not have sufficient state budget to defend.", | |
| metadata={ | |
| "required_cost": self._action_cost(amount), | |
| "cost_state_key": self._cost_state_key(), | |
| }, | |
| ) | |
| if self._target_self(): | |
| recipient = agent | |
| candidate = None | |
| else: | |
| selected = self._select_target(agent=agent, world=world) | |
| if selected is None: | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="DefendBehavior found no matching target to defend.", | |
| metadata={ | |
| "target_selector": self._target_selector(), | |
| "radius": self._radius(), | |
| }, | |
| ) | |
| recipient, candidate = selected | |
| defense_updates = self._defense_updates(recipient=recipient, amount=amount) | |
| actor_updates: dict[str, Any] = {} | |
| target_updates: dict[str, Any] = {} | |
| if recipient.id == agent.id: | |
| actor_updates.update(defense_updates) | |
| else: | |
| target_updates.update(defense_updates) | |
| cost_updates = self._build_cost_state_updates( | |
| agent=agent, | |
| intensity=amount, | |
| existing_updates=actor_updates, | |
| charge=True, | |
| ) | |
| actor_updates.update(cost_updates) | |
| if recipient.id != agent.id: | |
| recipient.update_state(target_updates) | |
| computation = ConflictComputation( | |
| conflict_kind="defend", | |
| actor_id=agent.id, | |
| actor_type=agent.type, | |
| target_id=recipient.id, | |
| target_type=recipient.type, | |
| target=candidate, | |
| actor_state_updates=actor_updates, | |
| target_state_updates=target_updates, | |
| damage=None, | |
| transferred_assets=tuple(), | |
| partial=False, | |
| metadata={ | |
| "defense_state_key": self._defense_state_key(), | |
| "amount": amount, | |
| "target_self": recipient.id == agent.id, | |
| "mode": self._defense_mode(), | |
| }, | |
| ) | |
| metadata = computation.to_metadata() | |
| return self.success( | |
| reward=self._success_reward(intensity=amount), | |
| state_updates=actor_updates, | |
| memory_updates={"conflict": metadata}, | |
| message="Agent performed defense behavior.", | |
| metadata=metadata, | |
| ) | |
| except Exception as exc: | |
| logger.exception( | |
| "DefendBehavior failed for agent_id=%s behavior_id=%s", | |
| agent.id, | |
| getattr(self, "id", self.__class__.__name__), | |
| ) | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="DefendBehavior raised an exception.", | |
| metadata={"error": repr(exc)}, | |
| ) | |
| def _defense_updates(self, *, recipient: Agent, amount: float) -> dict[str, Any]: | |
| """Build state updates for defense.""" | |
| key = self._defense_state_key() | |
| mode = self._defense_mode() | |
| current = self._numeric_state(recipient, key, default=0.0) | |
| if mode == "increment": | |
| new_value = current + amount | |
| elif mode == "set": | |
| new_value = amount | |
| elif mode == "multiply": | |
| new_value = current * amount | |
| else: | |
| raise ValueError("Defend mode must be one of: increment, set, multiply.") | |
| new_value = self._apply_defense_clamps(new_value) | |
| return {key: new_value} | |
| def _defense_state_key(self) -> str: | |
| """Return required defense state key.""" | |
| value = self._optional_string_parameter( | |
| "defense_state_key", | |
| aliases=("state_key", "output_state_key", "protection_state_key"), | |
| default=None, | |
| ) | |
| return value or "defense" | |
| def _defense_amount(self) -> float: | |
| """Return configured defense amount.""" | |
| for key in ("amount", "defense_amount", "shield_amount", "value"): | |
| if key in self.parameters and self.parameters[key] is not None: | |
| return self._normalize_non_negative_float(self.parameters[key], key) | |
| return DEFAULT_DEFENSE_AMOUNT | |
| def _defense_mode(self) -> str: | |
| """Return defense state update mode.""" | |
| mode = str(self.parameters.get("mode", "increment")).strip().lower() | |
| if mode in {"add", "increase"}: | |
| return "increment" | |
| if mode in {"assign"}: | |
| return "set" | |
| if mode in {"scale"}: | |
| return "multiply" | |
| return mode | |
| def _target_self(self) -> bool: | |
| """Return whether defense applies to the actor.""" | |
| return self._optional_bool_parameter("target_self", default=True) | |
| def _apply_defense_clamps(self, value: float) -> float: | |
| """Apply optional defense state clamps.""" | |
| new_value = value | |
| min_value = self._optional_finite_float_parameter( | |
| "min_state_value", | |
| aliases=("defense_state_min",), | |
| default=None, | |
| ) | |
| max_value = self._optional_finite_float_parameter( | |
| "max_state_value", | |
| aliases=("defense_state_max",), | |
| default=None, | |
| ) | |
| if min_value is not None: | |
| new_value = max(new_value, min_value) | |
| if max_value is not None: | |
| new_value = min(new_value, max_value) | |
| return new_value | |
| class RaidBehavior(ConflictBehavior): | |
| """Transfer assets from a target to the acting agent.""" | |
| def _check_preconditions(self, agent: Agent, world: WorldProtocol) -> bool: | |
| """Check raid-specific preconditions.""" | |
| try: | |
| if not self._raid_assets() and not self._has_optional_damage_configured(): | |
| return False | |
| intensity = self._raid_cost_intensity() | |
| if not self._has_sufficient_action_budget(agent=agent, intensity=intensity): | |
| return False | |
| return self._select_target(agent=agent, world=world) is not None | |
| except Exception: | |
| logger.exception( | |
| "Raid precondition failed for agent_id=%s behavior_id=%s", | |
| agent.id, | |
| getattr(self, "id", self.__class__.__name__), | |
| ) | |
| return False | |
| def execute(self, agent: Agent, world: WorldProtocol) -> BehaviorResult: | |
| """Execute raid behavior.""" | |
| try: | |
| selected = self._select_target(agent=agent, world=world) | |
| if selected is None: | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="RaidBehavior found no matching target.", | |
| metadata={ | |
| "target_selector": self._target_selector(), | |
| "radius": self._radius(), | |
| }, | |
| ) | |
| target, candidate = selected | |
| requested_assets = self._raid_assets() | |
| intensity = self._raid_cost_intensity() | |
| if not self._has_sufficient_action_budget(agent=agent, intensity=intensity): | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="Agent does not have sufficient state budget to raid.", | |
| metadata={ | |
| "required_cost": self._action_cost(intensity), | |
| "cost_state_key": self._cost_state_key(), | |
| }, | |
| ) | |
| actor_updates: dict[str, Any] = {} | |
| target_updates: dict[str, Any] = {} | |
| actor_updates.update( | |
| self._build_cost_state_updates( | |
| agent=agent, | |
| intensity=intensity, | |
| existing_updates=actor_updates, | |
| charge=True, | |
| ) | |
| ) | |
| hit = self._roll_hit(world) | |
| damage: DamageComputation | None = None | |
| if self._has_optional_damage_configured(): | |
| damage_updates, damage = self._compute_damage_updates( | |
| actor=agent, | |
| target=target, | |
| base_amount=self._attack_amount(default=0.0), | |
| hit=hit, | |
| ) | |
| target_updates.update(damage_updates) | |
| if not hit: | |
| computation = ConflictComputation( | |
| conflict_kind="raid", | |
| actor_id=agent.id, | |
| actor_type=agent.type, | |
| target_id=target.id, | |
| target_type=target.type, | |
| target=candidate, | |
| actor_state_updates=actor_updates, | |
| target_state_updates={}, | |
| damage=damage, | |
| transferred_assets=tuple(), | |
| partial=False, | |
| metadata={"hit": False}, | |
| ) | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| state_updates=actor_updates, | |
| memory_updates={"conflict": computation.to_metadata()}, | |
| message="RaidBehavior missed target.", | |
| metadata=computation.to_metadata(), | |
| ) | |
| raid_fraction = self._raid_fraction( | |
| target=target, | |
| assets=requested_assets, | |
| pending_target_updates=target_updates, | |
| ) | |
| if requested_assets and raid_fraction <= ZERO_TOLERANCE: | |
| computation = ConflictComputation( | |
| conflict_kind="raid", | |
| actor_id=agent.id, | |
| actor_type=agent.type, | |
| target_id=target.id, | |
| target_type=target.type, | |
| target=candidate, | |
| actor_state_updates=actor_updates, | |
| target_state_updates=target_updates, | |
| damage=damage, | |
| transferred_assets=tuple(), | |
| partial=False, | |
| metadata={"reason": "insufficient_target_assets"}, | |
| ) | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| state_updates=actor_updates, | |
| memory_updates={"conflict": computation.to_metadata()}, | |
| message="RaidBehavior could not transfer requested assets.", | |
| metadata=computation.to_metadata(), | |
| ) | |
| executed_assets = tuple(asset.scaled(raid_fraction) for asset in requested_assets) | |
| for asset in executed_assets: | |
| self._subtract_asset( | |
| agent=target, | |
| pending_updates=target_updates, | |
| asset=asset, | |
| amount=asset.amount, | |
| ) | |
| self._add_asset( | |
| agent=agent, | |
| pending_updates=actor_updates, | |
| asset=asset, | |
| amount=asset.amount, | |
| ) | |
| if target_updates: | |
| target.update_state(target_updates) | |
| if damage is not None and damage.marked_dead: | |
| target.mark_dead( | |
| reason=f"Threshold reached by raid behavior '{getattr(self, 'id', self.name)}'." | |
| ) | |
| partial = raid_fraction < 1.0 - ZERO_TOLERANCE | |
| computation = ConflictComputation( | |
| conflict_kind="raid", | |
| actor_id=agent.id, | |
| actor_type=agent.type, | |
| target_id=target.id, | |
| target_type=target.type, | |
| target=candidate, | |
| actor_state_updates=actor_updates, | |
| target_state_updates=target_updates, | |
| damage=damage, | |
| transferred_assets=executed_assets, | |
| partial=partial, | |
| metadata={ | |
| "hit": True, | |
| "raid_fraction": raid_fraction, | |
| "target_alive_after": target.alive, | |
| }, | |
| ) | |
| metadata = computation.to_metadata() | |
| effective_intensity = 0.0 if damage is None else damage.effective_amount | |
| return self.success( | |
| reward=self._success_reward( | |
| intensity=effective_intensity, | |
| fraction=raid_fraction, | |
| ), | |
| state_updates=actor_updates, | |
| memory_updates={"conflict": metadata}, | |
| message="Agent raided target.", | |
| metadata=metadata, | |
| ) | |
| except Exception as exc: | |
| logger.exception( | |
| "RaidBehavior failed for agent_id=%s behavior_id=%s", | |
| agent.id, | |
| getattr(self, "id", self.__class__.__name__), | |
| ) | |
| return self.failure( | |
| reward=self._failure_reward(), | |
| message="RaidBehavior raised an exception.", | |
| metadata={"error": repr(exc)}, | |
| ) | |
| def _raid_assets(self) -> tuple[RaidAsset, ...]: | |
| """Return raid assets configured by parameters.""" | |
| raw_value = None | |
| for key in ("raid_assets", "assets", "loot", "steal"): | |
| if key in self.parameters and self.parameters[key] is not None: | |
| raw_value = self.parameters[key] | |
| break | |
| if raw_value is None and "item_key" in self.parameters: | |
| raw_value = { | |
| "key": self.parameters["item_key"], | |
| "amount": self.parameters.get( | |
| "quantity", | |
| self.parameters.get("amount", DEFAULT_RAID_AMOUNT), | |
| ), | |
| "container": self.parameters.get("item_container", "inventory"), | |
| "inventory_key": self.parameters.get( | |
| "item_inventory_key", | |
| self._inventory_key(), | |
| ), | |
| } | |
| if raw_value is None: | |
| return tuple() | |
| if isinstance(raw_value, Mapping): | |
| raw_assets = [raw_value] | |
| elif isinstance(raw_value, Sequence) and not isinstance(raw_value, (str, bytes)): | |
| raw_assets = list(raw_value) | |
| else: | |
| raise TypeError("raid_assets must be a mapping or sequence of mappings.") | |
| return tuple( | |
| RaidAsset.from_mapping( | |
| raw_asset, | |
| default_container=RaidAsset._normalize_container( | |
| self.parameters.get("item_container", "inventory") | |
| ), | |
| default_inventory_key=self._inventory_key(), | |
| ) | |
| for raw_asset in raw_assets | |
| ) | |
| def _raid_fraction( | |
| self, | |
| *, | |
| target: Agent, | |
| assets: Sequence[RaidAsset], | |
| pending_target_updates: Mapping[str, Any], | |
| ) -> float: | |
| """Compute executable fraction of requested raid assets.""" | |
| if not assets: | |
| return 1.0 | |
| available_fraction = min( | |
| 1.0, | |
| self._available_asset_fraction( | |
| agent=target, | |
| assets=assets, | |
| pending_updates=pending_target_updates, | |
| ), | |
| ) | |
| if not self._allow_partial_raid(): | |
| return 1.0 if available_fraction >= 1.0 - ZERO_TOLERANCE else 0.0 | |
| minimum_fraction = self._minimum_raid_fraction() | |
| if available_fraction < minimum_fraction: | |
| return 0.0 | |
| return max(0.0, min(1.0, available_fraction)) | |
| def _raid_cost_intensity(self) -> float: | |
| """Return intensity used for optional raid cost.""" | |
| asset_total = sum(asset.amount for asset in self._raid_assets()) | |
| damage_amount = ( | |
| self._attack_amount(default=0.0) | |
| if self._has_optional_damage_configured() | |
| else 0.0 | |
| ) | |
| return asset_total + damage_amount | |
| def _has_optional_damage_configured(self) -> bool: | |
| """Return whether raid also includes target state mutation.""" | |
| return self._target_state_key() is not None or bool(self._target_state_updates()) | |
| def _allow_partial_raid(self) -> bool: | |
| """Return whether partial raids are allowed.""" | |
| return self._optional_bool_parameter("allow_partial", default=True) | |
| def _minimum_raid_fraction(self) -> float: | |
| """Return minimum partial raid fraction.""" | |
| value = self._optional_non_negative_float_parameter( | |
| "minimum_raid_fraction", | |
| aliases=("min_raid_fraction", "minimum_trade_fraction"), | |
| default=ZERO_TOLERANCE, | |
| ) | |
| assert value is not None | |
| if value > 1.0: | |
| raise ValueError("minimum_raid_fraction cannot exceed 1.0.") | |
| return value | |
| Attack = AttackBehavior | |
| Hunt = HuntBehavior | |
| Defend = DefendBehavior | |
| Raid = RaidBehavior | |
| BEHAVIOR_REGISTRY: Mapping[str, type[Behavior]] = MappingProxyType( | |
| { | |
| "attack": AttackBehavior, | |
| "hunt": HuntBehavior, | |
| "defend": DefendBehavior, | |
| "raid": RaidBehavior, | |
| } | |
| ) | |
| __all__ = [ | |
| "AssetContainer", | |
| "Attack", | |
| "AttackBehavior", | |
| "BEHAVIOR_REGISTRY", | |
| "ConflictBehavior", | |
| "ConflictComputation", | |
| "ConflictTargetCandidate", | |
| "DamageComputation", | |
| "DEFAULT_ATTACK_AMOUNT", | |
| "DEFAULT_DEFENSE_AMOUNT", | |
| "DEFAULT_INVENTORY_KEY", | |
| "DEFAULT_RAID_AMOUNT", | |
| "Defend", | |
| "DefendBehavior", | |
| "Hunt", | |
| "HuntBehavior", | |
| "Raid", | |
| "RaidAsset", | |
| "RaidBehavior", | |
| "TargetSelectionStrategy", | |
| ] |