| """Deterministic village core loop: hunger, resources, beasts, memory, trust. |
| |
| The LLM may select one action/tool, but this module owns every world mutation: |
| position, health, inventory, resource amounts, beast state, memory episodes, and |
| relationship changes all flow through deterministic validation/effects here. |
| |
| Distances in this module are in world units. The task design is expressed in |
| "tiles"; one tile is one terrain block (``BLOCK_SIZE`` world units), so tile-based |
| thresholds are scaled by ``TILE`` below. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| import os |
| import random |
|
|
| from world_simulator.domain import ( |
| Beast, |
| CannonState, |
| CountryState, |
| ElectionState, |
| House, |
| MemoryEntry, |
| MemoryEpisode, |
| Npc, |
| ResourceNode, |
| TreasuryState, |
| Vec3, |
| WorldEvent, |
| WorldLogEvent, |
| WorldState, |
| ) |
| from world_simulator.simulation.connectors.base import NpcDirective, TickPlan |
| from world_simulator.simulation.directives import clear_active_directive |
| from world_simulator.simulation.mechanics import ( |
| ATTACK_RADIUS, |
| BLOCK_SIZE, |
| TALK_RADIUS, |
| is_alive, |
| move_away, |
| move_toward, |
| vec_distance, |
| ) |
| from world_simulator.simulation.memory import remember, strip_tick_entries |
| from world_simulator.simulation.roles import ( |
| canonical_action, |
| normalize_role, |
| ) |
|
|
| TILE = BLOCK_SIZE |
|
|
| |
| HUNGER_RATE = 0.8 |
| STARVATION_THRESHOLD = 70.0 |
| SEVERE_STARVATION_THRESHOLD = 90.0 |
| STARVATION_DAMAGE = 0.5 |
| SEVERE_STARVATION_DAMAGE = 1.5 |
| HUNGER_CAP = 150.0 |
| HEAL_GOAL_HEALTH = 40 |
| FIND_HERBS_HEALTH = 50 |
| EAT_GOAL_HUNGER = 60.0 |
| REPRODUCTION_SNACK_HUNGER = 45.0 |
| |
| |
| |
| REPRODUCTION_MAX_HUNGER = 25.0 |
|
|
| |
| BEAST_ALERT_RADIUS = 6 * TILE |
| BEAST_THREAT_RADIUS = 8 * TILE |
| VILLAGE_THREAT_RADIUS = 10 * TILE |
| HOUSE_THREAT_RADIUS = 12 * TILE |
| ALLY_RADIUS = 5 * TILE |
| WITNESS_RADIUS = 10.0 |
| FEAR_DECAY_RADIUS = 12 * TILE |
| GATHER_RADIUS = 1.5 * TILE |
| TRADE_RADIUS = 2 * TILE |
| PERCEPTION_BEAST_RADIUS = 8 * TILE |
| PERCEPTION_RESOURCE_RADIUS = 6 * TILE |
| PERCEPTION_NPC_RADIUS = 6 * TILE |
| HELP_RADIUS = 6 * TILE |
| SPEECH_RADIUS = 6 * TILE |
| HOUSE_RADIUS = 2.5 * TILE |
| HOUSE_BUILD_RADIUS = 2 * TILE |
| HOUSE_MIN_DISTANCE = 4 * TILE |
| |
| |
| HOUSE_REPAIR_PER_ACTION = 12.0 |
| HOUSE_REPAIR_WOOD_COST = 1 |
|
|
| |
| FEAR_ON_ATTACK = 60.0 |
| FEAR_ON_WITNESS = 20.0 |
| FEAR_ON_DEATH_BY_BEAST = 30.0 |
| FEAR_DECAY = 2.0 |
| SAFETY_GAIN = 12.0 |
| SAFETY_DECAY = 8.0 |
|
|
| |
| |
| NPC_MOVE_STEP = 1 * TILE |
| FLEE_STEP = 2 * TILE |
| NPC_MELEE_REACH = ATTACK_RADIUS |
| BASE_NPC_DAMAGE = 10 |
| WEAPON_DAMAGE_BONUS = 0 |
|
|
| |
| |
| |
| |
| COINS_PER_GATHER = 1 |
| COINS_PER_BUILD_TICK = 1 |
| COINS_ON_BUILD_COMPLETE = 4 |
| COINS_PER_BEAST_KILL = 6 |
| GUARD_DAMAGE_MULTIPLIER = 1.5 |
| WEAK_DAMAGE_MULTIPLIER = 0.3 |
| BEAST_RETREAT_HEALTH = 20.0 |
| BEAST_DESPAWN_TICKS = 15 |
|
|
| |
| |
| RESOURCE_SPAWN_INTERVAL = 7 |
| MAX_RESOURCE_NODES = 18 |
| BEAST_SPAWN_INTERVAL = 60 |
| MAX_BEASTS = 3 |
|
|
| |
| |
| RESOURCE_REGEN_INTERVALS = {"food": 8, "herbs": 12, "wood": 10, "weapon": 40} |
| RESOURCE_REGEN_AMOUNT = 1 |
| |
| SPREAD_NODE_CHOICES = 3 |
| |
| SHARE_FOOD_SURPLUS = 2 |
|
|
| DEFAULT_ELECTION_INTERVAL_TICKS = 50 |
| DEFAULT_ELECTION_DURATION_TICKS = 10 |
| DEFAULT_CANNON_MIN_POPULATION = 6 |
| DEFAULT_CANNON_TREASURY_COST = 20 |
| DEFAULT_CANNON_WOOD_COST = 15 |
| DEFAULT_CANNON_HP_TAX_PER_CITIZEN = 5 |
| DEFAULT_CANNON_DAMAGE = 35 |
| DEFAULT_CANNON_RADIUS = 5 |
| DEFAULT_CANNON_COOLDOWN_TICKS = 20 |
|
|
| |
| |
| GOAL_ACTIONS: dict[str, list[str]] = { |
| "engage_threat": ["attack", "defend", "communicate", "patrol"], |
| "survive_threat_fight": ["attack", "defend", "communicate", "flee", "go_home"], |
| "survive_threat_flee": ["flee", "communicate", "defend"], |
| "help_ally": ["attack", "defend", "communicate"], |
| "heal_self": ["heal"], |
| "eat_food": ["consume"], |
| "find_food": ["move_to_resource", "gather", "communicate", "steal"], |
| "find_herbs": ["move_to_resource", "gather", "communicate"], |
| "obey_directive": ["attack", "defend", "flee", "go_home", "move_to_resource", "gather", "communicate", "build", "patrol", "idle"], |
| "settle": ["go_home", "consume", "communicate", "transfer"], |
| "work": ["gather", "move_to_resource", "transfer", "communicate", "build", "patrol"], |
| "routine_life": ["gather", "move_to_resource", "transfer", "communicate", "go_home", "build", "patrol"], |
| "dead": [], |
| } |
|
|
| |
| |
| GOAL_SUGGESTED_ACTIONS: dict[str, list[str]] = { |
| "engage_threat": ["attack", "move", "speak"], |
| "survive_threat_fight": ["attack", "move", "speak"], |
| "survive_threat_flee": ["move", "speak"], |
| "help_ally": ["attack", "speak"], |
| "heal_self": ["use"], |
| "eat_food": ["use"], |
| "find_food": ["move", "use", "speak", "transfer"], |
| "find_herbs": ["move", "use", "speak"], |
| "obey_directive": ["speak", "move", "attack", "use", "transfer"], |
| "settle": ["move", "use", "speak", "transfer"], |
| "work": ["use", "move", "transfer", "speak"], |
| "routine_life": ["use", "move", "transfer", "speak"], |
| "dead": [], |
| } |
|
|
| PRIMITIVE_SURVIVAL_ACTIONS = frozenset( |
| {"move", "speak", "attack", "use", "transfer", "vote"} |
| ) |
|
|
| |
| |
| |
| VALID_SURVIVAL_VERBS = frozenset( |
| { |
| "gather", |
| "consume", |
| "heal", |
| "attack", |
| "steal", |
| "communicate", |
| "transfer", |
| "flee", |
| "defend", |
| "idle", |
| "move_to_resource", |
| "move_to", |
| "find_food", |
| "find_herbs", |
| "go_home", |
| "patrol", |
| "build", |
| "craft", |
| "assign_cannon_operator", |
| "fire", |
| "vote", |
| } |
| ) |
|
|
|
|
| |
| |
| |
| def add_episode( |
| npc: Npc, |
| tick: int, |
| kind: str, |
| actor_id: str, |
| summary: str, |
| *, |
| target_id: str | None = None, |
| object_id: str | None = None, |
| subject_kind: str | None = None, |
| perspective: str = "self", |
| tags: list[str] | tuple[str, ...] | None = None, |
| weight: float = 0.5, |
| ) -> None: |
| """Add a structured memory episode and update trust from generic primitives.""" |
| normalized_tags = _dedupe_tags(tags or ()) |
| episode = MemoryEpisode( |
| tick=tick, |
| kind=kind, |
| actor_id=actor_id, |
| target_id=target_id, |
| object_id=object_id, |
| subject_kind=subject_kind or _infer_subject_kind(actor_id, object_id), |
| perspective=perspective, |
| summary=summary, |
| emotional_weight=_clamp(weight, 0.0, 1.0), |
| tags=normalized_tags, |
| ) |
| npc.structured_memory.episodes.append(episode) |
| _update_relationship_from_episode(npc, episode) |
| if len(npc.structured_memory.episodes) > 20: |
| npc.structured_memory.episodes.sort( |
| key=lambda item: (item.emotional_weight, item.tick), |
| reverse=True, |
| ) |
| npc.structured_memory.episodes = npc.structured_memory.episodes[:20] |
| npc.structured_memory.episodes.sort(key=lambda item: item.tick) |
|
|
|
|
| def _infer_subject_kind(actor_id: str, object_id: str | None) -> str: |
| if actor_id.startswith("beast"): |
| return "beast" |
| if actor_id.startswith("npc"): |
| return "npc" |
| if object_id is not None: |
| return "resource" |
| return "world" |
|
|
|
|
| def _dedupe_tags(tags: list[str] | tuple[str, ...]) -> list[str]: |
| deduped: list[str] = [] |
| for raw_tag in tags: |
| tag = str(raw_tag).strip().lower() |
| if tag and tag not in deduped: |
| deduped.append(tag) |
| return deduped |
|
|
|
|
| def _update_relationship_from_episode(npc: Npc, episode: MemoryEpisode) -> None: |
| if episode.subject_kind == "beast" and ( |
| episode.kind == "attack" or "danger" in episode.tags or "beast" in episode.tags |
| ): |
| npc.relationships[episode.actor_id] = -1.0 |
| return |
|
|
| if episode.subject_kind != "npc" or episode.actor_id == npc.id: |
| return |
|
|
| if episode.kind == "steal": |
| delta = -0.5 if episode.perspective == "recipient" else -0.25 |
| _adjust_relationship(npc, episode.actor_id, delta) |
| return |
|
|
| if episode.kind == "attack": |
| if "help" in episode.tags and episode.perspective in {"recipient", "witness"}: |
| _adjust_relationship(npc, episode.actor_id, 0.3) |
| return |
| delta = -0.55 if episode.perspective == "recipient" else -0.25 |
| _adjust_relationship(npc, episode.actor_id, delta) |
| return |
|
|
| if episode.kind in {"defend", "heal", "transfer"} and "help" in episode.tags: |
| delta = 0.25 if episode.perspective == "recipient" else 0.1 |
| _adjust_relationship(npc, episode.actor_id, delta) |
|
|
|
|
| def compress_to_facts(npc: Npc) -> None: |
| """Compress repeated episodes into simple semantic facts.""" |
| facts: list[str] = [] |
| by_actor: dict[str, list[MemoryEpisode]] = {} |
| for episode in npc.structured_memory.episodes: |
| by_actor.setdefault(episode.actor_id, []).append(episode) |
|
|
| for actor_id, episodes in sorted(by_actor.items()): |
| danger_events = [ |
| episode |
| for episode in episodes |
| if "danger" in episode.tags or episode.kind in {"attack", "steal"} |
| ] |
| helpful_events = [ |
| episode |
| for episode in episodes |
| if "help" in episode.tags and episode.kind in {"attack", "defend", "transfer", "heal"} |
| ] |
| betrayal_events = [ |
| episode |
| for episode in episodes |
| if episode.kind == "steal" or "betrayal" in episode.tags |
| ] |
| if actor_id.startswith("beast") and danger_events: |
| facts.append(f"{actor_id} is dangerous") |
| if helpful_events: |
| facts.append(f"{actor_id} is friendly") |
| if betrayal_events: |
| facts.append(f"{actor_id} is untrustworthy") |
| if any(episode.kind == "attack" and episode.subject_kind == "npc" for episode in episodes): |
| facts.append(f"{actor_id} is dangerous") |
|
|
| deduped: list[str] = [] |
| for fact in facts: |
| if fact not in deduped: |
| deduped.append(fact) |
| npc.structured_memory.facts = deduped[:10] |
|
|
|
|
| def build_memory_summary(npc: Npc, *, last_n: int = 3, current_tick: int | None = None) -> str: |
| lines: list[str] = [] |
| for fact in npc.structured_memory.facts[:10]: |
| lines.append(f"- {fact}") |
|
|
| recent = sorted(npc.structured_memory.episodes, key=lambda item: item.tick)[-last_n:] |
| for episode in recent: |
| age = "" |
| if current_tick is not None: |
| delta = max(0, current_tick - episode.tick) |
| age = f" ({delta} ticks ago)" |
| tag = _memory_tag(episode) |
| lines.append(f"- {episode.summary}{age} [{tag}]") |
|
|
| if not lines: |
| legacy = [memory.text for memory in npc.memory[-last_n:]] |
| lines.extend(f"- {text}" for text in legacy) |
|
|
| return "\n".join(lines) if lines else "- no important memories" |
|
|
|
|
| def build_relationships_summary(npc: Npc, world: WorldState | None = None) -> str: |
| names = {other.id: other.name for other in world.living_npcs()} if world is not None else {} |
| ids = set(npc.relationships) |
| if world is not None: |
| ids.update(other.id for other in world.living_npcs() if other.id != npc.id) |
| ids.update(beast.id for beast in world.beasts) |
|
|
| lines: list[str] = [] |
| for target_id in sorted(ids): |
| if target_id == npc.id: |
| continue |
| trust = -1.0 if target_id.startswith("beast") else npc.relationships.get(target_id, 0.0) |
| label = _relationship_label(trust) |
| display = names.get(target_id, target_id) |
| if target_id.startswith("beast"): |
| lines.append(f"- {display}: enemy (fear: {trust:.1f})") |
| else: |
| lines.append(f"- {display}: {label} (trust: {trust:.1f})") |
| return "\n".join(lines) if lines else "- no known relationships" |
|
|
|
|
| def build_perception(npc: Npc, world: WorldState) -> str: |
| lines: list[str] = [] |
| country = country_for_npc(world, npc) |
| if country is not None: |
| ruler = _npc_by_id(world, country.ruler_id or "") |
| treasury = country.treasury.resources if country.treasury is not None else {} |
| lines.append( |
| f"- your country is {country.name} [{country.badge}], ruler=" |
| f"{ruler.name if ruler else country.ruler_id or 'none'}" |
| ) |
| lines.append(f"- country policy: {country.policy}") |
| lines.append( |
| "- treasury " |
| + " ".join(f"{key}={value}" for key, value in sorted(treasury.items())) |
| ) |
| if country.cannon is not None: |
| operator = _npc_by_id(world, country.cannon.operator_id or "") |
| lines.append( |
| f"- cannon {country.cannon.id} operator=" |
| f"{operator.name if operator else country.cannon.operator_id or 'none'}" |
| ) |
| election = active_election_for_country(world, country.id) |
| if election is not None: |
| lines.append( |
| f"- election active until tick {election.end_tick}; candidates: " |
| + ", ".join(election.candidate_ids) |
| ) |
| for rival in world.countries: |
| if rival.id == country.id: |
| continue |
| rival_ruler = _npc_by_id(world, rival.ruler_id or "") |
| rival_treasury = rival.treasury.resources if rival.treasury is not None else {} |
| lines.append( |
| f"- rival country {rival.name} [{rival.badge}] ruler=" |
| f"{rival_ruler.name if rival_ruler else rival.ruler_id or 'none'}; " |
| f"treasury {rival.treasury.id if rival.treasury else 'none'} " |
| + " ".join(f"{key}={value}" for key, value in sorted(rival_treasury.items())) |
| ) |
|
|
| for beast in sorted(world.beasts, key=lambda item: vec_distance(npc.position, item.position)): |
| if beast.state == "dead": |
| continue |
| distance = vec_distance(npc.position, beast.position) |
| if distance <= PERCEPTION_BEAST_RADIUS: |
| lines.append( |
| f"- {beast.id} is {_tiles(distance):.0f} tiles " |
| f"{_direction(npc.position, beast.position)} [danger]" |
| ) |
|
|
| for node in sorted(world.resource_nodes, key=lambda item: vec_distance(npc.position, item.position)): |
| distance = vec_distance(npc.position, node.position) |
| if distance <= PERCEPTION_RESOURCE_RADIUS and node.amount > 0: |
| lines.append( |
| f"- {node.id} is {_tiles(distance):.0f} tiles " |
| f"{_direction(npc.position, node.position)} " |
| f"[{node.resource_type}={node.amount}]" |
| ) |
|
|
| for house in sorted(world.houses, key=lambda item: vec_distance(npc.position, item.position)): |
| distance = vec_distance(npc.position, house.position) |
| if distance <= PERCEPTION_RESOURCE_RADIUS: |
| lines.append( |
| f"- {house.id} is {_tiles(distance):.0f} tiles " |
| f"{_direction(npc.position, house.position)} " |
| f"[{house.state} hp={house.hp:.0f} occupants={len(house.occupant_ids)}/{house.capacity}]" |
| ) |
|
|
| for other in sorted(npcs_in_radius(world, npc.position, PERCEPTION_NPC_RADIUS, exclude_id=npc.id), key=lambda item: item.id): |
| trust = npc.relationships.get(other.id, 0.0) |
| label = _relationship_label(trust) |
| country_label = other.country_id or "no-country" |
| inventory = [f"{label} trust={trust:.1f}", f"country={country_label}"] |
| if other.inventory_food: |
| inventory.append(f"food={other.inventory_food}") |
| if other.inventory_weapon: |
| inventory.append(f"weapon={other.inventory_weapon}") |
| if other.hunger > STARVATION_THRESHOLD and other.inventory_food == 0: |
| inventory.append("hungry") |
| lines.append(f"- {other.name} nearby [{' '.join(inventory)}]") |
|
|
| for event in world.active_events: |
| if event.position is None or vec_distance(npc.position, event.position) <= event.radius: |
| lines.append(f"- [EVENT] {event.description}") |
|
|
| needs: list[str] = [] |
| if npc.hunger > STARVATION_THRESHOLD: |
| needs.append(f"hunger={npc.hunger:.0f}") |
| if npc.health < FIND_HERBS_HEALTH: |
| needs.append(f"health={npc.health:.0f}") |
| if npc.fear > 50: |
| needs.append(f"fear={npc.fear:.0f}") |
| if needs: |
| lines.append(f"- own urgent needs [{' '.join(needs)}]") |
|
|
| return "\n".join(lines) if lines else "- nothing urgent nearby" |
|
|
|
|
| |
| |
| |
| def nearest_beast(world: WorldState, position: Vec3) -> Beast | None: |
| living = [beast for beast in world.beasts if beast.state != "dead"] |
| if not living: |
| return None |
| return min(living, key=lambda beast: (vec_distance(position, beast.position), beast.id)) |
|
|
|
|
| def nearest_beast_distance(world: WorldState, npc: Npc) -> float: |
| beast = nearest_beast(world, npc.position) |
| if beast is None: |
| return float("inf") |
| return vec_distance(npc.position, beast.position) |
|
|
|
|
| def find_nearest_alive_npc( |
| world: WorldState, |
| position: Vec3, |
| *, |
| exclude_id: str | None = None, |
| ) -> Npc | None: |
| candidates = [ |
| npc |
| for npc in world.living_npcs() |
| if npc.id != exclude_id |
| ] |
| if not candidates: |
| return None |
| return min(candidates, key=lambda npc: (vec_distance(position, npc.position), npc.id)) |
|
|
|
|
| def nearest_alive_npc( |
| world: WorldState, |
| npc: Npc, |
| *, |
| max_dist: float, |
| require_food: bool = False, |
| ) -> Npc | None: |
| candidates = [ |
| other |
| for other in world.living_npcs() |
| if other.id != npc.id |
| and vec_distance(npc.position, other.position) <= max_dist |
| and (not require_food or other.inventory_food > 0) |
| ] |
| if not candidates: |
| return None |
| return min(candidates, key=lambda other: (vec_distance(npc.position, other.position), other.id)) |
|
|
|
|
| def _nearest_role( |
| world: WorldState, |
| npc: Npc, |
| role: str, |
| *, |
| max_dist: float, |
| ) -> Npc | None: |
| candidates = [ |
| other |
| for other in world.living_npcs() |
| if other.id != npc.id |
| and normalize_role(other.role) == role |
| and vec_distance(npc.position, other.position) <= max_dist |
| ] |
| if not candidates: |
| return None |
| return min(candidates, key=lambda other: (vec_distance(npc.position, other.position), other.id)) |
|
|
|
|
| def nearest_resource( |
| world: WorldState, |
| position: Vec3, |
| *, |
| resource_type: str | None = None, |
| max_dist: float | None = None, |
| require_amount: bool = True, |
| ) -> ResourceNode | None: |
| candidates = [ |
| node |
| for node in world.resource_nodes |
| if (resource_type is None or node.resource_type == resource_type) |
| and (not require_amount or node.amount > 0) |
| and (max_dist is None or vec_distance(position, node.position) <= max_dist) |
| ] |
| if not candidates: |
| return None |
| return min(candidates, key=lambda node: (vec_distance(position, node.position), node.id)) |
|
|
|
|
| def npcs_in_radius( |
| world: WorldState, |
| position: Vec3, |
| radius: float, |
| *, |
| exclude_id: str | None = None, |
| ) -> list[Npc]: |
| return [ |
| npc |
| for npc in world.living_npcs() |
| if npc.id != exclude_id |
| and vec_distance(position, npc.position) <= radius |
| ] |
|
|
|
|
| def count_alive_npcs_in_radius(world: WorldState, position: Vec3, radius: float) -> int: |
| return sum( |
| 1 |
| for npc in world.living_npcs() |
| if vec_distance(position, npc.position) <= radius |
| ) |
|
|
|
|
| def count_alive_population(world: WorldState) -> int: |
| return len(world.living_npcs()) |
|
|
|
|
| def completed_houses(world: WorldState) -> list[House]: |
| return [house for house in world.houses if house.state == "completed" and house.hp > 0] |
|
|
|
|
| def house_containing_npc(world: WorldState, npc: Npc) -> House | None: |
| for house in completed_houses(world): |
| if vec_distance(npc.position, house.position) <= HOUSE_RADIUS: |
| return house |
| return None |
|
|
|
|
| def is_npc_inside_house(world: WorldState, npc: Npc) -> bool: |
| return house_containing_npc(world, npc) is not None |
|
|
|
|
| def nearest_house(world: WorldState, position: Vec3, *, completed_only: bool = True) -> House | None: |
| houses = completed_houses(world) if completed_only else [house for house in world.houses if house.hp > 0] |
| if not houses: |
| return None |
| return min(houses, key=lambda house: (vec_distance(position, house.position), house.id)) |
|
|
|
|
| def nearest_resource_for_npc( |
| world: WorldState, |
| npc: Npc, |
| *, |
| resource_type: str | None = None, |
| max_dist: float | None = None, |
| require_amount: bool = True, |
| ) -> ResourceNode | None: |
| if normalize_role(npc.role) == "builder": |
| resource_type = "wood" |
| return nearest_resource( |
| world, |
| npc.position, |
| resource_type=resource_type, |
| max_dist=max_dist, |
| require_amount=require_amount, |
| ) |
|
|
|
|
| def country_for_npc(world: WorldState, npc: Npc) -> CountryState | None: |
| if npc.country_id is None: |
| return None |
| return next((country for country in world.countries if country.id == npc.country_id), None) |
|
|
|
|
| def active_election_for_country( |
| world: WorldState, |
| country_id: str, |
| *, |
| tick: int | None = None, |
| ) -> ElectionState | None: |
| current_tick = world.tick if tick is None else tick |
| for election in world.elections: |
| if election.country_id != country_id or election.completed: |
| continue |
| if election.start_tick <= current_tick <= election.end_tick: |
| return election |
| return None |
|
|
|
|
| def active_election_for_npc(world: WorldState, npc: Npc, *, tick: int | None = None) -> ElectionState | None: |
| if npc.country_id is None: |
| return None |
| return active_election_for_country(world, npc.country_id, tick=tick) |
|
|
|
|
| def _env_int(name: str, default: int) -> int: |
| raw = os.getenv(name) |
| if raw is None: |
| return default |
| try: |
| value = int(raw) |
| except ValueError: |
| return default |
| return value if value > 0 else default |
|
|
|
|
| def apply_world_systems(world: WorldState) -> None: |
| """Run deterministic environment systems before NPC decisions.""" |
| |
| from world_simulator.simulation.chaos import end_famine_if_due |
|
|
| end_famine_if_due(world) |
| if world.resource_nodes: |
| _regrow_resource_nodes(world) |
| _maybe_spawn_resource_node(world) |
| if world.beasts or world.resource_nodes: |
| _maybe_spawn_beast(world) |
|
|
|
|
| def _regrow_resource_nodes(world: WorldState) -> None: |
| for node in world.resource_nodes: |
| interval = RESOURCE_REGEN_INTERVALS.get(node.resource_type, 10) |
| if world.tick % interval == 0 and node.max_amount > 0 and node.amount < node.max_amount: |
| node.amount = min(node.max_amount, node.amount + RESOURCE_REGEN_AMOUNT) |
|
|
|
|
| def _maybe_spawn_resource_node(world: WorldState) -> None: |
| if world.tick == 0 or world.tick % RESOURCE_SPAWN_INTERVAL != 0: |
| return |
| if len(world.resource_nodes) >= MAX_RESOURCE_NODES: |
| return |
|
|
| rng = random.Random(f"{world.seed}:{world.tick}:resource_spawn") |
| resource_type = rng.choices( |
| ["food", "herbs", "wood", "weapon"], |
| weights=[6, 3, 3, 1], |
| k=1, |
| )[0] |
| max_amount = {"food": 5, "herbs": 4, "wood": 6, "weapon": 2}[resource_type] |
| node_index = _next_resource_index(world, resource_type) |
| world.resource_nodes.append( |
| ResourceNode( |
| id=f"res_{resource_type}_{node_index}", |
| resource_type=resource_type, |
| position=_random_map_position(world, rng), |
| amount=max(1, rng.randint(max(1, max_amount // 2), max_amount)), |
| max_amount=max_amount, |
| ) |
| ) |
|
|
|
|
| def _maybe_spawn_beast(world: WorldState) -> None: |
| living_beasts = [beast for beast in world.beasts if beast.state != "dead"] |
| if len(living_beasts) >= MAX_BEASTS: |
| return |
| if count_alive_population(world) < 4: |
| return |
| cooldown = _natural_beast_spawn_interval(world) |
| if world.tick == 0 or world.tick % cooldown != 0: |
| return |
|
|
| rng = random.Random(f"{world.seed}:{world.tick}:beast_spawn") |
| beast = Beast( |
| id=f"beast_{_next_beast_index(world)}", |
| position=_random_map_edge_position(world, rng), |
| health=60.0, |
| damage=9.0, |
| ) |
| world.beasts.append(beast) |
| _log_event( |
| world, |
| "beast_spawned", |
| f"{beast.id} appeared at the edge of the map", |
| severity="warning", |
| actor_id=beast.id, |
| ) |
|
|
|
|
| def _natural_beast_spawn_interval(world: WorldState) -> int: |
| rng = random.Random(f"{world.seed}:beast_spawn_interval:{world.tick // BEAST_SPAWN_INTERVAL}") |
| return rng.randint(60, 90) |
|
|
|
|
| def _random_map_edge_position(world: WorldState, rng: random.Random) -> Vec3: |
| half_width = world.terrain.width / 2 |
| half_depth = world.terrain.depth / 2 |
| side = rng.choice(["north", "south", "east", "west"]) |
| if side == "north": |
| return Vec3(x=round(rng.uniform(-half_width, half_width), 3), y=0.0, z=half_depth) |
| if side == "south": |
| return Vec3(x=round(rng.uniform(-half_width, half_width), 3), y=0.0, z=-half_depth) |
| if side == "east": |
| return Vec3(x=half_width, y=0.0, z=round(rng.uniform(-half_depth, half_depth), 3)) |
| return Vec3(x=-half_width, y=0.0, z=round(rng.uniform(-half_depth, half_depth), 3)) |
|
|
|
|
| def _next_resource_index(world: WorldState, resource_type: str) -> int: |
| prefix = f"res_{resource_type}_" |
| highest = 0 |
| for node in world.resource_nodes: |
| if not node.id.startswith(prefix): |
| continue |
| suffix = node.id.removeprefix(prefix) |
| if suffix.isdigit(): |
| highest = max(highest, int(suffix)) |
| return highest + 1 |
|
|
|
|
| def _next_beast_index(world: WorldState) -> int: |
| highest = 0 |
| for beast in world.beasts: |
| suffix = beast.id.removeprefix("beast_") |
| if suffix.isdigit(): |
| highest = max(highest, int(suffix)) |
| return highest + 1 |
|
|
|
|
| def _random_map_position(world: WorldState, rng: random.Random) -> Vec3: |
| half_width = world.terrain.width / 2 |
| half_depth = world.terrain.depth / 2 |
| return Vec3( |
| x=round(rng.uniform(-half_width + TILE, half_width - TILE), 3), |
| y=0.0, |
| z=round(rng.uniform(-half_depth + TILE, half_depth - TILE), 3), |
| ) |
|
|
|
|
| def _select_beast_target(world: WorldState, beast: Beast) -> Npc | None: |
| candidates = [ |
| npc |
| for npc in world.living_npcs() |
| if not is_npc_inside_house(world, npc) |
| ] |
| if not candidates: |
| return None |
| nearest_distance = min(vec_distance(beast.position, npc.position) for npc in candidates) |
| vulnerable = [ |
| npc |
| for npc in candidates |
| if npc.health < 50 and vec_distance(beast.position, npc.position) <= nearest_distance * 1.3 |
| ] |
| pool = vulnerable or candidates |
| return min(pool, key=lambda npc: (vec_distance(beast.position, npc.position), npc.health, npc.id)) |
|
|
|
|
| def _select_house_target(world: WorldState, beast: Beast) -> House | None: |
| houses = completed_houses(world) |
| if not houses: |
| return None |
| occupied_ids = set() |
| for npc in world.living_npcs(): |
| house = house_containing_npc(world, npc) |
| if house is not None: |
| occupied_ids.add(house.id) |
| occupied = [house for house in houses if house.id in occupied_ids] |
| pool = occupied or houses |
| return min(pool, key=lambda house: (vec_distance(beast.position, house.position), house.id)) |
|
|
|
|
| def _record_world_event( |
| world: WorldState, |
| kind: str, |
| description: str, |
| position: Vec3 | None, |
| *, |
| radius: float, |
| duration_ticks: int, |
| ) -> None: |
| world.active_events.append( |
| WorldEvent( |
| tick_created=world.tick, |
| kind=kind, |
| description=description, |
| position=position, |
| radius=radius, |
| duration_ticks=duration_ticks, |
| ) |
| ) |
|
|
|
|
| def _log_event( |
| world: WorldState, |
| event_type: str, |
| summary: str, |
| *, |
| severity: str = "neutral", |
| actor_id: str | None = None, |
| target_id: str | None = None, |
| object_id: str | None = None, |
| tick: int | None = None, |
| ) -> None: |
| normalized_severity = severity if severity in {"good", "neutral", "warning", "danger"} else "neutral" |
| world.event_log.append( |
| WorldLogEvent( |
| tick=world.tick if tick is None else tick, |
| type=event_type, |
| actor_id=actor_id, |
| target_id=target_id, |
| object_id=object_id, |
| summary=summary, |
| severity=normalized_severity, |
| ) |
| ) |
|
|
|
|
| |
| |
| |
| def apply_survival_tick(world: WorldState) -> None: |
| """Advance hunger, the beast, fear, and starvation deaths for one tick. |
| |
| No-op for worlds without survival entities, so the existing social-only |
| pipeline and its tests are unaffected. |
| """ |
| if world.active_events: |
| world.active_events = [ |
| event |
| for event in world.active_events |
| if world.tick - event.tick_created < event.duration_ticks |
| ] |
|
|
| apply_world_systems(world) |
|
|
| if not world.beasts and not world.resource_nodes and not world.houses: |
| return |
|
|
| _refresh_house_occupants(world) |
|
|
| for npc in list(world.living_npcs()): |
| npc.age += 1 |
| npc.reproduction_cooldown = max(0, npc.reproduction_cooldown - 1) |
| npc.hunger = min(HUNGER_CAP, npc.hunger + HUNGER_RATE) |
| if npc.hunger >= SEVERE_STARVATION_THRESHOLD: |
| npc.health -= SEVERE_STARVATION_DAMAGE |
| elif npc.hunger >= STARVATION_THRESHOLD: |
| npc.health -= STARVATION_DAMAGE |
| if npc.health <= 0: |
| _kill_npc(world, npc, cause="starvation", actor_id=npc.id) |
| continue |
| if npc.age >= npc.max_age: |
| _kill_npc(world, npc, cause="old_age", actor_id=npc.id) |
|
|
| _apply_beast_system(world) |
| _refresh_house_occupants(world) |
|
|
| for npc in world.living_npcs(): |
| _update_safety(world, npc) |
| if nearest_beast_distance(world, npc) > FEAR_DECAY_RADIUS: |
| npc.fear = max(0.0, npc.fear - FEAR_DECAY) |
| |
| |
| npc.survival_goal = select_goal(npc, world) |
| if world.tick > 0 and world.tick % 10 == 0: |
| compress_to_facts(npc) |
|
|
|
|
| def apply_post_survival_tick(world: WorldState, next_tick: int) -> None: |
| """Run deterministic end-of-tick systems after NPC actions resolve.""" |
| _refresh_house_occupants(world) |
| _refresh_country_citizens(world, next_tick) |
| _run_election_system(world, next_tick) |
| for npc in list(world.npcs): |
| if is_alive(npc): |
| _maybe_reproduce(world, npc, next_tick) |
| _refresh_house_occupants(world) |
| _refresh_country_citizens(world, next_tick) |
| if next_tick % 10 == 0: |
| _recompute_importance(world) |
| _update_population_and_status(world, next_tick) |
|
|
|
|
| def _refresh_country_citizens(world: WorldState, next_tick: int) -> None: |
| living_ids = {npc.id for npc in world.living_npcs()} |
| for country in world.countries: |
| country.citizen_ids = [ |
| npc.id |
| for npc in world.npcs |
| if npc.country_id == country.id and npc.id in living_ids |
| ] |
| if country.ruler_id not in living_ids or country.ruler_id not in country.citizen_ids: |
| country.ruler_id = country.citizen_ids[0] if country.citizen_ids else None |
| for npc in world.npcs: |
| if npc.country_id != country.id: |
| continue |
| npc.special_status = "ruler" if npc.id == country.ruler_id else ( |
| "cannon_operator" if country.cannon and country.cannon.operator_id == npc.id else None |
| ) |
|
|
| def _run_election_system(world: WorldState, next_tick: int) -> None: |
| for country in world.countries: |
| active = active_election_for_country(world, country.id, tick=next_tick) |
| if active is None and country.citizen_ids and next_tick >= country.next_election_tick: |
| _start_election(world, country, next_tick) |
| active = active_election_for_country(world, country.id, tick=next_tick) |
| if active is not None and next_tick >= active.end_tick: |
| _finish_election(world, country, active, next_tick) |
|
|
|
|
| def _start_election(world: WorldState, country: CountryState, tick: int) -> None: |
| duration = _env_int("ELECTION_DURATION_TICKS", DEFAULT_ELECTION_DURATION_TICKS) |
| candidate_ids = list(country.citizen_ids) |
| election = ElectionState( |
| country_id=country.id, |
| start_tick=tick, |
| end_tick=tick + duration, |
| candidate_ids=candidate_ids, |
| ) |
| world.elections.append(election) |
| _log_event( |
| world, |
| "election_started", |
| f"{country.name} election started with {len(candidate_ids)} candidates", |
| severity="neutral", |
| object_id=country.id, |
| tick=tick, |
| ) |
|
|
|
|
| def _finish_election( |
| world: WorldState, |
| country: CountryState, |
| election: ElectionState, |
| tick: int, |
| ) -> None: |
| for npc_id in country.citizen_ids: |
| if npc_id in election.votes: |
| continue |
| election.votes[npc_id] = _fallback_vote(world, election, voter_id=npc_id) |
| counts: dict[str, int] = {} |
| for candidate_id in election.votes.values(): |
| if candidate_id in election.candidate_ids: |
| counts[candidate_id] = counts.get(candidate_id, 0) + 1 |
| if counts: |
| winner_id = min(counts, key=lambda candidate_id: (-counts[candidate_id], candidate_id)) |
| country.ruler_id = winner_id |
| _set_country_policy(world, country, tick) |
| election.completed = True |
| country.next_election_tick = tick + _env_int( |
| "ELECTION_INTERVAL_TICKS", |
| DEFAULT_ELECTION_INTERVAL_TICKS, |
| ) |
| _refresh_country_citizens(world, tick) |
| _log_event( |
| world, |
| "election_completed", |
| f"{country.name} elected {country.ruler_id} with {len(election.votes)} votes", |
| severity="good", |
| target_id=country.ruler_id, |
| object_id=country.id, |
| tick=tick, |
| ) |
|
|
|
|
| def _fallback_vote(world: WorldState, election: ElectionState, *, voter_id: str) -> str: |
| if not election.candidate_ids: |
| return voter_id |
| rng = random.Random(f"{world.seed}:{election.country_id}:{election.start_tick}:{voter_id}") |
| return rng.choice(election.candidate_ids) |
|
|
|
|
| def _set_country_policy(world: WorldState, country: CountryState, tick: int) -> None: |
| treasury = country.treasury.resources if country.treasury else {} |
| wood = treasury.get("wood", 0) |
| coins = treasury.get("coins", 0) |
| if wood >= DEFAULT_CANNON_WOOD_COST and coins >= DEFAULT_CANNON_TREASURY_COST: |
| country.policy = "Craft a cannon, protect the treasury, and attack only clear threats." |
| elif coins < 8: |
| country.policy = "Gather coins and wood, defend citizens, and avoid reckless fights." |
| else: |
| country.policy = "Gather resources, help weak citizens, and watch the other country." |
| _log_event( |
| world, |
| "policy_updated", |
| f"{country.name} policy: {country.policy}", |
| severity="neutral", |
| actor_id=country.ruler_id, |
| object_id=country.id, |
| tick=tick, |
| ) |
|
|
|
|
| def _apply_beast_system(world: WorldState) -> None: |
| half_width = world.terrain.width / 2 |
| half_depth = world.terrain.depth / 2 |
|
|
| for beast in world.beasts: |
| if beast.state == "dead": |
| continue |
|
|
| target = _select_beast_target(world, beast) |
| house_target = None if target is not None else _select_house_target(world, beast) |
| beast.target_npc_id = target.id if target is not None else None |
| beast.target_house_id = house_target.id if house_target is not None else None |
|
|
| target_position = target.position if target is not None else ( |
| house_target.position if house_target is not None else None |
| ) |
| if target_position is None: |
| continue |
|
|
| step = beast.speed * TILE |
| reach = ATTACK_RADIUS |
|
|
| if beast.health < BEAST_RETREAT_HEALTH: |
| beast.state = "retreating" |
| beast.retreat_ticks += 1 |
| beast.position = move_away( |
| beast.position, |
| target_position, |
| step, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| if beast.retreat_ticks >= BEAST_DESPAWN_TICKS: |
| beast.state = "dead" |
| _log_event( |
| world, |
| "beast_retreat", |
| f"{beast.id} fled the map", |
| severity="neutral", |
| actor_id=beast.id, |
| ) |
| continue |
|
|
| beast.retreat_ticks = 0 |
| distance = vec_distance(beast.position, target_position) |
| if distance > reach: |
| beast.state = "hunting" |
| beast.position = move_toward( |
| beast.position, |
| target_position, |
| step, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| continue |
|
|
| beast.state = "attacking" |
| if target is not None: |
| _beast_attack_npc(world, beast, target) |
| elif house_target is not None: |
| _damage_house(world, house_target, beast, amount=5.0) |
|
|
|
|
| def _beast_attack_npc(world: WorldState, beast: Beast, target: Npc) -> None: |
| if is_npc_inside_house(world, target): |
| house = house_containing_npc(world, target) |
| if house is not None: |
| _damage_house(world, house, beast, amount=5.0) |
| return |
|
|
| target.health -= beast.damage |
| target.fear = min(100.0, max(target.fear, FEAR_ON_ATTACK)) |
| add_episode( |
| target, |
| world.tick, |
| "attack", |
| beast.id, |
| f"{beast.id} attacked me", |
| target_id=target.id, |
| subject_kind="beast", |
| perspective="recipient", |
| tags=["danger", "beast"], |
| weight=1.0, |
| ) |
| _record_world_event( |
| world, |
| "beast_attack", |
| f"{beast.id} attacked {target.name}", |
| beast.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=4, |
| ) |
| _log_event( |
| world, |
| "beast_attack", |
| f"{beast.id} attacked {target.name}", |
| severity="danger", |
| actor_id=beast.id, |
| target_id=target.id, |
| ) |
| remember(target, world.tick, f"Beast {beast.id} attacked me at tick {world.tick}.") |
| if target.health <= 0: |
| _kill_npc(world, target, cause="beast", actor_id=beast.id) |
|
|
| for witness in npcs_in_radius(world, beast.position, WITNESS_RADIUS, exclude_id=target.id): |
| witness.fear = min(100.0, witness.fear + FEAR_ON_WITNESS) |
| add_episode( |
| witness, |
| world.tick, |
| "attack", |
| beast.id, |
| f"{beast.id} attacked {target.name} nearby", |
| target_id=target.id, |
| subject_kind="beast", |
| perspective="witness", |
| tags=["danger", "beast"], |
| weight=0.8, |
| ) |
| remember(witness, world.tick, f"The beast attacked {target.name} nearby.") |
|
|
|
|
| def _damage_house(world: WorldState, house: House, beast: Beast, *, amount: float) -> None: |
| house.hp = max(0.0, house.hp - amount) |
| _record_world_event( |
| world, |
| "house_damaged", |
| f"{beast.id} damaged {house.id}", |
| house.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=4, |
| ) |
| _log_event( |
| world, |
| "house_damaged", |
| f"{beast.id} damaged {house.id}", |
| severity="warning", |
| actor_id=beast.id, |
| target_id=house.id, |
| ) |
| if house.hp > 0: |
| return |
|
|
| house.state = "destroyed" |
| house.build_progress = 0 |
| house.occupant_ids = [] |
| if _chaos_points_active(world): |
| world.chaos_score += 2 |
| _record_world_event( |
| world, |
| "house_destroyed", |
| f"{house.id} was destroyed", |
| house.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=6, |
| ) |
| _log_event( |
| world, |
| "house_destroyed", |
| f"{house.id} was destroyed", |
| severity="danger", |
| actor_id=beast.id, |
| target_id=house.id, |
| ) |
| for npc in npcs_in_radius(world, house.position, HOUSE_RADIUS): |
| npc.fear = max(npc.fear, 80.0) |
| npc.safety = 0.0 |
| remember(npc, world.tick, f"{house.id} was destroyed around you.") |
|
|
|
|
| def _kill_npc(world: WorldState, npc: Npc, *, cause: str, actor_id: str | None) -> None: |
| if npc.health <= 0 and npc.intention == "dead": |
| return |
| npc.health = min(npc.health, 0) |
| npc.intention = "dead" |
| npc.survival_goal = "dead" |
| clear_active_directive(npc) |
| world.deaths_by_cause[cause] = world.deaths_by_cause.get(cause, 0) + 1 |
| if _chaos_points_active(world): |
| world.chaos_score += 3 |
| add_episode( |
| npc, |
| world.tick, |
| "death", |
| actor_id or npc.id, |
| f"{npc.name} died of {cause}", |
| target_id=npc.id, |
| subject_kind="beast" if actor_id and actor_id.startswith("beast") else "npc", |
| perspective="recipient", |
| tags=["danger", "death", cause], |
| weight=1.0, |
| ) |
| remember(npc, world.tick, "You died.") |
| _drop_inventory(world, npc) |
| _record_world_event( |
| world, |
| "npc_died", |
| f"{npc.name} died of {cause}", |
| npc.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=6, |
| ) |
| _log_event( |
| world, |
| "npc_died", |
| f"{npc.name} died of {cause}", |
| severity="danger", |
| actor_id=actor_id, |
| target_id=npc.id, |
| object_id=cause, |
| ) |
| for witness in npcs_in_radius(world, npc.position, WITNESS_RADIUS, exclude_id=npc.id): |
| actor_is_beast = bool(actor_id and actor_id.startswith("beast")) |
| actor_is_npc = bool(actor_id and actor_id.startswith("npc")) |
| if actor_is_beast: |
| witness.fear = min(100.0, witness.fear + FEAR_ON_DEATH_BY_BEAST) |
| witness.relationships[actor_id or "beast"] = -1.0 |
| else: |
| witness.fear = min(100.0, witness.fear + FEAR_ON_WITNESS) |
| if actor_is_npc and actor_id is not None: |
| _adjust_relationship(witness, actor_id, -0.5) |
| add_episode( |
| witness, |
| world.tick, |
| "death", |
| actor_id or npc.id, |
| f"{npc.name} died of {cause}", |
| target_id=npc.id, |
| subject_kind="beast" if actor_is_beast else "npc", |
| perspective="witness", |
| tags=["danger", "death", cause], |
| weight=0.9, |
| ) |
| remember(witness, world.tick, f"{npc.name} died of {cause}.") |
| _purge_dead_npc_references(world, npc) |
|
|
|
|
| def _drop_inventory(world: WorldState, npc: Npc) -> None: |
| for resource_type, amount in ( |
| ("food", npc.inventory_food), |
| ("herbs", npc.inventory_herbs), |
| ("wood", npc.inventory_wood), |
| ("weapon", npc.inventory_weapon), |
| ("coins", npc.inventory_coins), |
| ): |
| if amount <= 0: |
| continue |
| index = _next_resource_index(world, resource_type) |
| world.resource_nodes.append( |
| ResourceNode( |
| id=f"res_{resource_type}_{index}", |
| resource_type=resource_type, |
| position=npc.position, |
| amount=amount, |
| max_amount=max( |
| amount, |
| {"food": 5, "herbs": 4, "wood": 6, "weapon": 2, "coins": 10}[resource_type], |
| ), |
| ) |
| ) |
| npc.inventory_food = 0 |
| npc.inventory_herbs = 0 |
| npc.inventory_wood = 0 |
| npc.inventory_weapon = 0 |
| npc.inventory_coins = 0 |
|
|
|
|
| def _purge_dead_npc_references(world: WorldState, dead: Npc) -> None: |
| for house in world.houses: |
| house.occupant_ids = [npc_id for npc_id in house.occupant_ids if npc_id != dead.id] |
| house.owner_ids = [npc_id for npc_id in house.owner_ids if npc_id != dead.id] |
|
|
| dead.focus_target_id = None |
| dead.help_target_id = None |
| dead.build_target_house_id = None |
| dead.home_house_id = None |
|
|
| dead_name = dead.name.lower() |
| dead_id = dead.id.lower() |
| for npc in world.living_npcs(): |
| if npc.focus_target_id == dead.id: |
| npc.focus_target_id = None |
| if npc.help_target_id == dead.id: |
| npc.help_target_id = None |
| if npc.god_directive: |
| lowered = npc.god_directive.lower() |
| if dead_id in lowered or dead_name in lowered: |
| clear_active_directive(npc) |
|
|
| for beast in world.beasts: |
| if beast.target_npc_id == dead.id: |
| beast.target_npc_id = None |
|
|
|
|
| def _refresh_house_occupants(world: WorldState) -> None: |
| for house in world.houses: |
| if house.state != "completed" or house.hp <= 0: |
| house.occupant_ids = [] |
| continue |
| house.occupant_ids = [ |
| npc.id |
| for npc in world.living_npcs() |
| if vec_distance(npc.position, house.position) <= HOUSE_RADIUS |
| ][: house.capacity] |
|
|
|
|
| def _update_safety(world: WorldState, npc: Npc) -> None: |
| house = house_containing_npc(world, npc) |
| if house is None: |
| npc.safety = max(0.0, npc.safety - SAFETY_DECAY) |
| npc.needs.safety = round(npc.safety) |
| return |
|
|
| threat_near_house = any( |
| beast.state != "dead" and vec_distance(beast.position, house.position) <= HOUSE_THREAT_RADIUS |
| for beast in world.beasts |
| ) or any( |
| other.id != npc.id |
| and vec_distance(other.position, npc.position) <= 10 * TILE |
| and _has_hostile_directive_toward(other, npc) |
| for other in world.living_npcs() |
| ) |
| if not threat_near_house: |
| npc.safety = min(100.0, npc.safety + SAFETY_GAIN) |
| npc.needs.safety = round(npc.safety) |
|
|
|
|
| def _has_hostile_directive_toward(actor: Npc, target: Npc) -> bool: |
| if not actor.god_directive: |
| return False |
| lowered = actor.god_directive.lower() |
| return "attack" in lowered or "kill" in lowered or target.name.lower() in lowered |
|
|
|
|
| def _maybe_reproduce(world: WorldState, parent: Npc, next_tick: int) -> None: |
| house = house_containing_npc(world, parent) |
| if house is None: |
| return |
| if not _reproduction_conditions_met(world, parent): |
| return |
|
|
| role = _role_for_child(world, parent) |
| child_index = _next_npc_index(world) |
| |
| |
| |
| child = Npc( |
| id=f"npc-{child_index:03d}", |
| name=_child_name(child_index), |
| role=role, |
| position=house.position, |
| health=70, |
| hunger=20.0, |
| fear=0.0, |
| safety=100.0 if not _house_has_threat(world, house) else 0.0, |
| age=0, |
| max_age=random.Random(f"{world.seed}:{next_tick}:{child_index}:max_age").randint(320, 480), |
| inventory_weapon=0, |
| home_house_id=house.id, |
| country_id=parent.country_id, |
| connector_id=parent.connector_id, |
| unrestricted_actions=parent.unrestricted_actions, |
| model_profile_id=parent.model_profile_id, |
| personality=parent.personality, |
| memory=[MemoryEntry(tick=next_tick, text=f"Born in {house.id} as a {role}.")], |
| ) |
| if parent.country_id is not None: |
| country = country_for_npc(world, parent) |
| if country is not None and child.id not in country.citizen_ids: |
| country.citizen_ids.append(child.id) |
| for npc in world.living_npcs(): |
| if npc.id == child.id: |
| continue |
| child.relationships[npc.id] = 0.45 |
| npc.relationships[child.id] = 0.45 |
| child.relationships[parent.id] = 0.6 |
| parent.relationships[child.id] = 0.6 |
| world.npcs.append(child) |
| parent.children_count += 1 |
| parent.reproduction_cooldown = 100 |
| parent.hunger = min(HUNGER_CAP, parent.hunger + 40.0) |
| world.total_births += 1 |
| world.overseer_score += 3 |
| add_episode( |
| parent, |
| next_tick, |
| "npc_born", |
| parent.id, |
| f"I had a child: {child.name}", |
| target_id=child.id, |
| subject_kind="npc", |
| perspective="self", |
| tags=["birth", "family"], |
| weight=0.8, |
| ) |
| for witness in npcs_in_radius(world, house.position, HOUSE_RADIUS, exclude_id=parent.id): |
| add_episode( |
| witness, |
| next_tick, |
| "npc_born", |
| parent.id, |
| f"{parent.name} had a child: {child.name}", |
| target_id=child.id, |
| subject_kind="npc", |
| perspective="witness", |
| tags=["birth", "family"], |
| weight=0.6, |
| ) |
| _log_event( |
| world, |
| "npc_born", |
| f"{parent.name} gave birth to {child.name}", |
| severity="good", |
| actor_id=parent.id, |
| target_id=child.id, |
| tick=next_tick, |
| ) |
|
|
|
|
| def _reproduction_conditions_met(world: WorldState, npc: Npc) -> bool: |
| return ( |
| npc.health >= 95 |
| and npc.hunger <= REPRODUCTION_MAX_HUNGER |
| and round(npc.safety) >= 100 |
| and npc.age > 90 |
| and npc.reproduction_cooldown == 0 |
| and count_alive_population(world) < world.population_cap |
| ) |
|
|
|
|
| def _house_has_threat(world: WorldState, house: House) -> bool: |
| return any( |
| beast.state != "dead" and vec_distance(beast.position, house.position) <= HOUSE_THREAT_RADIUS |
| for beast in world.beasts |
| ) |
|
|
|
|
| def _role_for_child(world: WorldState, parent: Npc) -> str: |
| |
| return "citizen" |
|
|
|
|
| def _next_npc_index(world: WorldState) -> int: |
| highest = 0 |
| for npc in world.npcs: |
| suffix = npc.id.removeprefix("npc-") |
| if suffix.isdigit(): |
| highest = max(highest, int(suffix)) |
| return highest + 1 |
|
|
|
|
| def _child_name(index: int) -> str: |
| names = [ |
| "Ada", |
| "Boris", |
| "Cora", |
| "Dima", |
| "Elena", |
| "Farid", |
| "Gita", |
| "Hana", |
| "Ivan", |
| "Juno", |
| "Kira", |
| "Lena", |
| "Mira", |
| "Niko", |
| "Oleg", |
| "Pavel", |
| "Raya", |
| "Sofia", |
| "Toma", |
| "Vera", |
| ] |
| return names[(index - 1) % len(names)] |
|
|
|
|
| def _recompute_importance(world: WorldState) -> None: |
| counts: dict[str, int] = {} |
| for npc in world.living_npcs(): |
| role = normalize_role(npc.role) |
| counts[role] = counts.get(role, 0) + 1 |
| for npc in world.npcs: |
| role_count = max(1, counts.get(normalize_role(npc.role), 1)) |
| role_scarcity = 1 / role_count |
| npc.importance = round( |
| 1.0 |
| + 2.0 * role_scarcity |
| + 0.5 * npc.beasts_killed |
| + 0.3 * npc.resources_transferred |
| + 0.4 * npc.children_count |
| + 0.5 * npc.houses_built, |
| 3, |
| ) |
|
|
|
|
| def _update_population_and_status(world: WorldState, next_tick: int) -> None: |
| population = count_alive_population(world) |
| world.population = population |
| world.peak_population = max(world.peak_population, population) |
| if world.game_status != "running": |
| return |
| if population <= 0: |
| world.game_status = "lose" |
| elif population >= world.population_cap: |
| world.game_status = "win_population" |
| elif next_tick >= 600: |
| world.game_status = "win_survival" |
| if world.game_status != "running": |
| _log_event( |
| world, |
| "game_over", |
| _game_over_summary(world), |
| severity="good" if world.game_status.startswith("win") else "danger", |
| tick=next_tick, |
| ) |
|
|
|
|
| def _game_over_summary(world: WorldState) -> str: |
| return ( |
| f"{world.game_status}: births={world.total_births}, deaths={sum(world.deaths_by_cause.values())}, " |
| f"houses_built={world.houses_built}, beasts_killed={world.beasts_killed}, " |
| f"peak_population={world.peak_population}, score={world.overseer_score}-{world.chaos_score}" |
| ) |
|
|
|
|
| def _chaos_points_active(world: WorldState) -> bool: |
| return world.tick <= world.chaos_intervention_until |
|
|
|
|
| |
| |
| |
| def select_goal(npc: Npc, world: WorldState) -> str: |
| if not is_alive(npc): |
| return "dead" |
|
|
| role = normalize_role(npc.role) |
|
|
| if npc.help_target_id: |
| target = _npc_by_id(world, npc.help_target_id) |
| if ( |
| target is not None |
| and is_alive(target) |
| and role == "guard" |
| and npc.relationships.get(target.id, 0.0) > 0.3 |
| and nearest_beast_distance(world, target) < BEAST_ALERT_RADIUS |
| ): |
| return "help_ally" |
| npc.help_target_id = None |
|
|
| |
| |
| |
| if npc.health < HEAL_GOAL_HEALTH and npc.inventory_herbs > 0: |
| return "heal_self" |
| if ( |
| _is_reproduction_candidate(npc) |
| and npc.inventory_food > 0 |
| and REPRODUCTION_MAX_HUNGER < npc.hunger <= REPRODUCTION_SNACK_HUNGER |
| ): |
| return "eat_food" |
| if npc.hunger >= EAT_GOAL_HUNGER and npc.inventory_food > 0: |
| return "eat_food" |
| if npc.god_directive: |
| return "obey_directive" |
| if npc.hunger >= EAT_GOAL_HUNGER: |
| return "find_food" |
| if npc.health < FIND_HERBS_HEALTH and npc.inventory_herbs == 0: |
| return "find_herbs" |
| if npc.health >= 80 and npc.hunger <= 30 and not _has_any_living_beast_near(world, npc.position, HOUSE_THREAT_RADIUS): |
| return "settle" |
| return "work" |
|
|
|
|
| def suggested_actions_for_goal(goal: str) -> list[str]: |
| return list(GOAL_SUGGESTED_ACTIONS.get(goal, [])) |
|
|
|
|
| def allowed_actions_for_goal(goal: str) -> list[str]: |
| return list(GOAL_ACTIONS.get(goal, [])) |
|
|
|
|
| def allowed_actions_for_npc(world: WorldState, npc: Npc, goal: str) -> list[str]: |
| allowed = allowed_actions_for_goal(goal) |
| if goal == "survive_threat_flee": |
| allowed = [action for action in allowed if action != "transfer"] |
| if "gather" in allowed and not _has_gatherable_resource_for_goal(world, npc, goal): |
| allowed = [action for action in allowed if action != "gather"] |
| if "consume" in allowed and npc.inventory_food <= 0: |
| allowed = [action for action in allowed if action != "consume"] |
| if "heal" in allowed and npc.inventory_herbs <= 0: |
| allowed = [action for action in allowed if action != "heal"] |
| return allowed or ["idle"] |
|
|
|
|
| def _has_gatherable_resource_for_goal(world: WorldState, npc: Npc, goal: str) -> bool: |
| resource_type: str | None = None |
| if goal == "find_food": |
| resource_type = "food" |
| elif goal == "find_herbs": |
| resource_type = "herbs" |
| elif normalize_role(npc.role) == "builder": |
| resource_type = "wood" |
| node = nearest_resource_for_npc(world, npc, resource_type=resource_type) |
| return node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS |
|
|
|
|
| def suggested_actions_for_npc(npc: Npc, goal: str) -> list[str]: |
| """Ranked LLM suggestions. Roles influence planning context, not tool access.""" |
| suggested = suggested_actions_for_goal(goal) |
| return suggested or ["move", "speak"] |
|
|
|
|
| def _beast_threatens_village(world: WorldState) -> bool: |
| for beast in world.beasts: |
| if beast.state == "dead": |
| continue |
| for house in completed_houses(world): |
| if vec_distance(beast.position, house.position) <= VILLAGE_THREAT_RADIUS: |
| return True |
| for npc in world.living_npcs(): |
| if vec_distance(beast.position, npc.position) <= VILLAGE_THREAT_RADIUS: |
| return True |
| return False |
|
|
|
|
| def _has_any_living_beast_near(world: WorldState, position: Vec3, radius: float) -> bool: |
| return any( |
| beast.state != "dead" and vec_distance(beast.position, position) <= radius |
| for beast in world.beasts |
| ) |
|
|
|
|
| def _is_reproduction_candidate(npc: Npc) -> bool: |
| return ( |
| npc.health >= 95 |
| and npc.age > 90 |
| and npc.reproduction_cooldown == 0 |
| and round(npc.safety) >= 100 |
| ) |
|
|
|
|
| def validate_survival_action( |
| npc: Npc, |
| action: str, |
| world: WorldState, |
| directive: NpcDirective | None = None, |
| ) -> str: |
| """Translate a requested action into a safe executable effect verb. |
| |
| Primitives are mapped onto effect verbs first; then role whitelists and |
| physical impossibility (missing nodes, empty inventory, no target) are |
| repaired so the engine never executes an illegal request. |
| """ |
| action = _canonical_action(_verb_for_action(npc, action, world, directive)) |
| if action not in VALID_SURVIVAL_VERBS: |
| return "idle" |
| resource_type = _resource_type_for_validation(world, npc, directive, action) |
| if action == "gather": |
| node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS) |
| if node is None or node.amount <= 0: |
| return "move_to_resource" |
| return "gather" |
| if action == "consume": |
| return "consume" if npc.inventory_food > 0 else "find_food" |
| if action == "heal": |
| return "heal" if npc.inventory_herbs > 0 else "find_herbs" |
| if action == "attack": |
| if _melee_target_npc(world, npc, directive) is not None: |
| return "attack" |
| beast = _beast_from_directive(world, npc, directive) |
| if beast is None: |
| return "defend" |
| return "attack" |
| if action == "build": |
| if npc.build_target_house_id: |
| house = _house_by_id(world, npc.build_target_house_id) |
| if house is not None and house.state == "under_construction": |
| return "build" |
| if ( |
| _nearest_damaged_house(world, npc.position) is not None |
| and npc.inventory_wood >= HOUSE_REPAIR_WOOD_COST |
| ): |
| return "build" |
| return "build" if npc.inventory_wood >= 5 else "move_to_resource" |
| if action == "craft": |
| return "craft" if _can_craft_cannon(world, npc) else "idle" |
| if action == "assign_cannon_operator": |
| return "assign_cannon_operator" if _can_assign_cannon_operator(world, npc, directive) else "idle" |
| if action == "fire": |
| return "fire" if _can_fire_cannon(world, npc, directive) else "idle" |
| if action == "go_home": |
| return "go_home" if nearest_house(world, npc.position) is not None else "move_to" |
| if action == "patrol": |
| return "patrol" |
| if action == "steal": |
| partner = _target_npc_from_directive(world, npc, directive, max_dist=TRADE_RADIUS) |
| if partner is None: |
| partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True) |
| return "steal" if partner is not None else "move_to_resource" |
| if action == "transfer": |
| if _treasury_from_directive(world, directive) is not None: |
| return "transfer" |
| partner = _target_npc_from_directive(world, npc, directive, max_dist=TRADE_RADIUS) |
| if partner is None: |
| partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS) |
| return "transfer" if partner is not None else "communicate" |
| if action == "vote": |
| election = active_election_for_npc(world, npc) |
| candidate_id = directive.target_npc_id if directive is not None else None |
| if election is None or candidate_id not in election.candidate_ids: |
| return "idle" |
| return "vote" |
| return action |
|
|
|
|
| def _verb_for_action( |
| npc: Npc, |
| action: str, |
| world: WorldState, |
| directive: NpcDirective | None, |
| ) -> str: |
| """Map a primitive plus its parameters onto an internal effect verb. |
| |
| Non-primitive verbs (already translated, or internal repairs) pass through. |
| """ |
| if action not in PRIMITIVE_SURVIVAL_ACTIONS: |
| return action |
|
|
| if action == "move": |
| if directive is not None and directive.away: |
| return "flee" |
| if directive is not None and ( |
| directive.resource_id or directive.resource_type or directive.target |
| ): |
| return "move_to_resource" |
| return "move_to" |
|
|
| if action == "speak": |
| return "communicate" |
|
|
| if action in {"attack", "strike"}: |
| return "attack" |
|
|
| if action == "use": |
| use_type = (directive.use_type or "").lower() if directive is not None else "" |
| if use_type in {"eat", "consume"}: |
| return "consume" |
| if use_type == "heal": |
| return "heal" |
| if use_type == "gather": |
| return "gather" |
| if use_type in {"build", "repair"}: |
| return "build" |
| if use_type == "craft": |
| return "craft" |
| if use_type == "assign_cannon_operator": |
| return "assign_cannon_operator" |
| if use_type == "fire": |
| return "fire" |
| if directive is not None and directive.resource_id: |
| return "gather" |
| resource_type = directive.resource_type if directive is not None else None |
| if resource_type == "food": |
| return "consume" |
| if resource_type == "herbs": |
| return "heal" |
| node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS) |
| if node is not None and node.amount > 0: |
| return "gather" |
| if npc.inventory_food > 0 and npc.hunger > STARVATION_THRESHOLD: |
| return "consume" |
| return "gather" |
|
|
| if action == "transfer": |
| |
| |
| |
| if _treasury_from_directive(world, directive) is not None: |
| return "transfer" |
| if directive is not None and directive.take: |
| return "steal" |
| return "transfer" |
|
|
| if action == "vote": |
| return "vote" |
|
|
| return "idle" |
|
|
|
|
| def _canonical_action(action: str) -> str: |
| return canonical_action(action) |
|
|
|
|
| def _resource_type_for_validation( |
| world: WorldState, |
| npc: Npc, |
| directive: NpcDirective | None, |
| action: str, |
| ) -> str | None: |
| if directive is not None and directive.resource_type: |
| return directive.resource_type |
| if directive is not None and directive.resource_id: |
| node = next((node for node in world.resource_nodes if node.id == directive.resource_id), None) |
| return node.resource_type if node is not None else None |
| if action in {"gather", "move_to_resource"}: |
| node = nearest_resource_for_npc(world, npc) |
| return node.resource_type if node is not None else None |
| return None |
|
|
|
|
| def _role_fallback_action(world: WorldState, npc: Npc) -> str: |
| role = normalize_role(npc.role) |
| if role == "guard": |
| if nearest_beast(world, npc.position) is not None: |
| return "attack" |
| return "patrol" |
| if role == "builder": |
| if npc.inventory_wood >= 5: |
| return "build" |
| return "move_to_resource" |
| if npc.inventory_food > 0 and npc.hunger >= EAT_GOAL_HUNGER: |
| return "consume" |
| return "move_to_resource" |
|
|
|
|
| def _border_block_summary( |
| dest: Vec3, |
| half_width: float, |
| half_depth: float, |
| ) -> str | None: |
| """When the NPC aimed PAST the world border (an off-map, impassable target), |
| return a short message it sees as LAST TURN feedback so it learns the move was |
| blocked at the edge. None for normal in-bounds targets. |
| Convention (matches the briefing compass): +X=WEST, -X=EAST, +Z=NORTH, -Z=SOUTH.""" |
| eps = 1e-3 |
| blocked: list[str] = [] |
| if dest.x > half_width + eps: |
| blocked.append("WEST") |
| elif dest.x < -half_width - eps: |
| blocked.append("EAST") |
| if dest.z > half_depth + eps: |
| blocked.append("NORTH") |
| elif dest.z < -half_depth - eps: |
| blocked.append("SOUTH") |
| if not blocked: |
| return None |
| edge = " and ".join(blocked) |
| return ( |
| f"you hit the world border to the {edge}: your target X={dest.x:g} Z={dest.z:g} " |
| f"is OFF THE MAP (it ends at X +-{half_width:g}, Z +-{half_depth:g}). You " |
| "can go no further that way - aim for a target INSIDE the map" |
| ) |
|
|
|
|
| |
| |
| |
| def apply_action_effects( |
| npc: Npc, |
| action: str, |
| world: WorldState, |
| *, |
| destination: Vec3 | None = None, |
| directive: NpcDirective | None = None, |
| ) -> str: |
| """Apply one validated survival action; return a short intention summary.""" |
| action = _verb_for_action(npc, action, world, directive) |
| npc.focus_target_id = None |
| half_width = world.terrain.width / 2 |
| half_depth = world.terrain.depth / 2 |
|
|
| if action == "consume": |
| if npc.inventory_food > 0: |
| npc.inventory_food -= 1 |
| npc.hunger = max(0.0, npc.hunger - 40.0) |
| npc.health = min(100, npc.health + 5) |
| add_episode( |
| npc, |
| world.tick, |
| "consume", |
| npc.id, |
| "I ate food", |
| object_id="food", |
| subject_kind="npc", |
| perspective="self", |
| tags=["food"], |
| weight=0.2, |
| ) |
| _log_event( |
| world, |
| "consume", |
| f"{npc.name} ate food", |
| actor_id=npc.id, |
| object_id="food", |
| ) |
| return "eating food" |
| return "searching for food" |
|
|
| if action == "heal": |
| if npc.inventory_herbs > 0: |
| npc.inventory_herbs -= 1 |
| npc.health = min(100, npc.health + 25) |
| _log_event( |
| world, |
| "heal", |
| f"{npc.name} healed with herbs", |
| severity="good", |
| actor_id=npc.id, |
| object_id="herbs", |
| ) |
| return "healing with herbs" |
| return "searching for herbs" |
|
|
| if action == "gather": |
| node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS) |
| if node is not None and node.amount > 0: |
| node.amount -= 1 |
| _add_to_inventory(npc, node.resource_type) |
| _earn_coins(npc, world, COINS_PER_GATHER, f"gathering {node.resource_type}") |
| add_episode( |
| npc, |
| world.tick, |
| "gather", |
| npc.id, |
| f"I gathered {node.resource_type} from {node.id}", |
| object_id=node.id, |
| subject_kind="resource", |
| perspective="self", |
| tags=[node.resource_type], |
| weight=0.3, |
| ) |
| remember(npc, world.tick, f"You gathered {node.resource_type}.") |
| _log_event( |
| world, |
| "gather", |
| f"{npc.name} gathered {node.resource_type}", |
| actor_id=npc.id, |
| object_id=node.id, |
| ) |
| return f"gathering {node.resource_type}" |
| return "searching for resources" |
|
|
| if action in ("move_to_resource", "find_food", "find_herbs"): |
| resource = _resource_from_directive(world, directive, npc) |
| dest = destination or (resource.position if resource is not None else None) or _resource_destination(world, npc, action) |
| if dest is not None: |
| border = _border_block_summary(dest, half_width, half_depth) |
| npc.position = move_toward( |
| npc.position, dest, NPC_MOVE_STEP, half_width=half_width, half_depth=half_depth |
| ) |
| if border is not None: |
| return border |
| return "heading to resources" |
| return "wandering" |
|
|
| if action == "move_to": |
| |
| rng = random.Random(f"{world.seed}:{world.tick}:{npc.id}:wander") |
| angle = rng.uniform(0.0, 2.0 * math.pi) |
| dest = Vec3( |
| x=npc.position.x + math.cos(angle) * NPC_MOVE_STEP * 3, |
| y=0.0, |
| z=npc.position.z + math.sin(angle) * NPC_MOVE_STEP * 3, |
| ) |
| npc.position = move_toward( |
| npc.position, dest, NPC_MOVE_STEP, half_width=half_width, half_depth=half_depth |
| ) |
| return "wandering" |
|
|
| if action == "go_home": |
| house = nearest_house(world, npc.position) |
| if house is not None: |
| npc.home_house_id = house.id |
| npc.position = move_toward( |
| npc.position, |
| house.position, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return f"going home to {house.id}" |
| return "looking for home" |
|
|
| if action == "patrol": |
| target = _patrol_target(world, npc) |
| if target is not None: |
| npc.position = move_toward( |
| npc.position, |
| target, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return "patrolling" |
| return "standing guard" |
|
|
| if action == "build": |
| return _apply_build(npc, world) |
|
|
| if action == "craft": |
| return _apply_craft(npc, world, directive) |
|
|
| if action == "assign_cannon_operator": |
| return _apply_assign_cannon_operator(npc, world, directive) |
|
|
| if action == "fire": |
| return _apply_fire_cannon(npc, world, directive) |
|
|
| if action == "flee": |
| beast = _beast_from_directive(world, npc, directive) |
| if beast is not None: |
| npc.position = move_away( |
| npc.position, |
| beast.position, |
| FLEE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return "fleeing the beast" |
| return "looking around" |
|
|
| if action == "attack": |
| target_npc = _melee_target_npc(world, npc, directive) |
| if target_npc is not None: |
| return _apply_npc_attack(npc, world, target_npc) |
| beast = _beast_from_directive(world, npc, directive) |
| if beast is None: |
| return "defending" |
| npc.focus_target_id = beast.id |
| if vec_distance(npc.position, beast.position) <= NPC_MELEE_REACH: |
| damage = _npc_damage_against_beast(npc) |
| beast.health -= damage |
| npc.relationships[beast.id] = -1.0 |
| add_episode( |
| npc, |
| world.tick, |
| "attack", |
| npc.id, |
| f"I struck {beast.id} for {damage:g} damage", |
| target_id=beast.id, |
| subject_kind="beast", |
| perspective="self", |
| tags=["danger", "beast"], |
| weight=0.8, |
| ) |
| remember(npc, world.tick, f"You struck {beast.id} for {damage:g} damage.") |
| _log_event( |
| world, |
| "npc_attack", |
| f"{npc.name} attacked {beast.id} for {damage:g}", |
| severity="warning", |
| actor_id=npc.id, |
| target_id=beast.id, |
| ) |
| if npc.help_target_id: |
| helped = _npc_by_id(world, npc.help_target_id) |
| if helped is not None and is_alive(helped): |
| add_episode( |
| helped, |
| world.tick, |
| "attack", |
| npc.id, |
| f"{npc.name} came to help against {beast.id}", |
| target_id=beast.id, |
| subject_kind="npc", |
| perspective="witness", |
| tags=["help", "danger", "beast"], |
| weight=0.8, |
| ) |
| if beast.health <= 0: |
| beast.state = "dead" |
| npc.beasts_killed += 1 |
| world.beasts_killed += 1 |
| world.overseer_score += 2 |
| _earn_coins(npc, world, COINS_PER_BEAST_KILL, f"slaying {beast.id}") |
| _record_world_event( |
| world, |
| "beast_killed", |
| f"{npc.name} killed {beast.id}", |
| beast.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=5, |
| ) |
| _log_event( |
| world, |
| "beast_killed", |
| f"{npc.name} killed {beast.id}", |
| severity="good", |
| actor_id=npc.id, |
| target_id=beast.id, |
| ) |
| remember(npc, world.tick, f"You killed {beast.id}!") |
| return "killed the beast" |
| return "fighting the beast" |
| npc.position = move_toward( |
| npc.position, |
| beast.position, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return "charging the beast" |
|
|
| if action == "transfer": |
| treasury = _treasury_from_directive(world, directive) |
| if treasury is not None: |
| return _apply_treasury_transfer(npc, world, treasury, directive) |
| |
| |
| partner = _transfer_partner(world, npc, directive) |
| resource_type = _resource_type_from_directive(directive, default="food") |
| amount = _amount_from_directive(directive, default=1) |
| can_transfer_food = ( |
| resource_type == "food" |
| and npc.inventory_food >= amount |
| and partner is not None |
| and (npc.inventory_food >= SHARE_FOOD_SURPLUS or partner.hunger > npc.hunger) |
| and partner.inventory_food < npc.inventory_food |
| ) |
| can_transfer_nonfood = ( |
| resource_type != "food" |
| and partner is not None |
| and _inventory_amount(npc, resource_type) >= amount |
| ) |
| if partner is not None and (can_transfer_food or can_transfer_nonfood): |
| npc.focus_target_id = partner.id |
| _remove_from_inventory(npc, resource_type, amount) |
| _add_to_inventory_amount(partner, resource_type, amount) |
| npc.resources_transferred += amount |
| _adjust_relationship(npc, partner.id, 0.2) |
| add_episode( |
| npc, |
| world.tick, |
| "transfer", |
| npc.id, |
| f"I gave {amount} {resource_type} to {partner.name}", |
| target_id=partner.id, |
| object_id=resource_type, |
| subject_kind="npc", |
| perspective="self", |
| tags=[resource_type, "help", "trade"], |
| weight=0.5, |
| ) |
| add_episode( |
| partner, |
| world.tick, |
| "transfer", |
| npc.id, |
| f"{npc.name} gave me {amount} {resource_type}", |
| target_id=partner.id, |
| object_id=resource_type, |
| subject_kind="npc", |
| perspective="recipient", |
| tags=[resource_type, "help", "trade"], |
| weight=0.7, |
| ) |
| remember(npc, world.tick, f"Gave {resource_type} to {partner.name}.") |
| remember(partner, world.tick, f"Received {resource_type} from {npc.name}.") |
| _log_event( |
| world, |
| "transfer", |
| f"{npc.name} gave {amount} {resource_type} to {partner.name}", |
| severity="good", |
| actor_id=npc.id, |
| target_id=partner.id, |
| object_id=resource_type, |
| ) |
| return f"sharing {resource_type} with {partner.name}" |
| return "looking for someone to trade with" |
|
|
| if action == "steal": |
| |
| |
| |
| partner = _target_npc_from_directive(world, npc, directive, max_dist=TRADE_RADIUS) |
| if partner is None: |
| partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True) |
| if partner is not None and partner.inventory_food > 0: |
| npc.focus_target_id = partner.id |
| partner.inventory_food -= 1 |
| npc.inventory_food += 1 |
| add_episode( |
| partner, |
| world.tick, |
| "steal", |
| npc.id, |
| f"{npc.name} stole from me", |
| target_id=partner.id, |
| object_id="food", |
| subject_kind="npc", |
| perspective="recipient", |
| tags=["food", "betrayal"], |
| weight=0.9, |
| ) |
| add_episode( |
| npc, |
| world.tick, |
| "steal", |
| npc.id, |
| f"I stole food from {partner.name}", |
| target_id=partner.id, |
| object_id="food", |
| subject_kind="npc", |
| perspective="self", |
| tags=["food", "betrayal"], |
| weight=0.7, |
| ) |
| remember(npc, world.tick, f"Stole food from {partner.name}.") |
| remember(partner, world.tick, f"{npc.name} stole food from you.") |
| return f"stealing from {partner.name}" |
| return "looking to steal" |
|
|
| if action == "communicate": |
| return _apply_communicate(npc, world, directive) |
|
|
| if action == "defend": |
| return "defending" |
|
|
| if action == "vote": |
| return _apply_vote(npc, world, directive) |
|
|
| return "idle" |
|
|
|
|
| def _apply_vote(npc: Npc, world: WorldState, directive: NpcDirective | None) -> str: |
| election = active_election_for_npc(world, npc) |
| candidate_id = directive.target_npc_id if directive is not None else None |
| if election is None or candidate_id not in election.candidate_ids: |
| return "unable to vote" |
| election.votes[npc.id] = candidate_id |
| candidate = _npc_by_id(world, candidate_id) |
| candidate_name = candidate.name if candidate is not None else candidate_id |
| _log_event( |
| world, |
| "vote_cast", |
| f"{npc.name} voted for {candidate_name}", |
| actor_id=npc.id, |
| target_id=candidate_id, |
| object_id=election.country_id, |
| ) |
| return f"voting for {candidate_name}" |
|
|
|
|
| def _apply_communicate( |
| npc: Npc, |
| world: WorldState, |
| directive: NpcDirective | None, |
| ) -> str: |
| intent = _communication_intent(directive, npc) |
| target = _target_npc_from_directive(world, npc, directive, max_dist=TALK_RADIUS) |
| message = directive.message if directive and directive.message else _message_for_intent(intent) |
| has_message = bool(directive is not None and directive.message) |
|
|
| if intent == "help_request": |
| add_episode( |
| npc, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"{npc.name} called for help", |
| subject_kind="npc", |
| perspective="self", |
| tags=["help", "danger"], |
| weight=0.6, |
| ) |
| helpers = resolve_call_for_help(npc, world) |
| helper_ids = {helper.id for helper in helpers} |
| for ally in npcs_in_radius(world, npc.position, HELP_RADIUS, exclude_id=npc.id): |
| ally.fear = min(100.0, ally.fear + 5.0) |
| if ally.id in helper_ids: |
| continue |
| add_episode( |
| ally, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"{npc.name} called for help", |
| subject_kind="npc", |
| perspective="recipient", |
| tags=["help", "danger"], |
| weight=0.6, |
| ) |
| _record_world_event( |
| world, |
| "communicate", |
| f"{npc.name} called for help", |
| npc.position, |
| radius=HELP_RADIUS, |
| duration_ticks=4, |
| ) |
| if helpers: |
| names = ", ".join(helper.name for helper in helpers) |
| return f"calling for help; {names} responded" |
| return "calling for help" |
|
|
| if intent == "trade_request": |
| if target is None: |
| target = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True) |
| if target is None: |
| return "looking for someone to trade with" |
| npc.focus_target_id = target.id |
| add_episode( |
| npc, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"I asked {target.name} for food", |
| target_id=target.id, |
| object_id="food", |
| subject_kind="npc", |
| perspective="self", |
| tags=["food", "trade"], |
| weight=0.4, |
| ) |
| add_episode( |
| target, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"{npc.name} asked me for food", |
| target_id=target.id, |
| object_id="food", |
| subject_kind="npc", |
| perspective="recipient", |
| tags=["food", "trade"], |
| weight=0.4, |
| ) |
| remember(npc, world.tick, f"You asked {target.name} for food.") |
| remember(target, world.tick, f"{npc.name} asked you for food.") |
| trust = target.relationships.get(npc.id, 0.0) |
| if target.inventory_food > SHARE_FOOD_SURPLUS and trust > 0.3: |
| transfer = NpcDirective( |
| npc_id=target.id, |
| action="transfer", |
| target_npc_id=npc.id, |
| resource_type="food", |
| amount=1, |
| ) |
| return apply_action_effects(target, "transfer", world, directive=transfer) |
| return f"requesting food from {target.name}: declined" |
|
|
| if target is not None: |
| npc.focus_target_id = target.id |
| |
| target.focus_target_id = npc.id |
| add_episode( |
| npc, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"I told {target.name}: {message}", |
| target_id=target.id, |
| subject_kind="npc", |
| perspective="self", |
| tags=["social"] if intent == "social" else [intent], |
| weight=0.3, |
| ) |
| add_episode( |
| target, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"{npc.name} told me: {message}", |
| target_id=target.id, |
| subject_kind="npc", |
| perspective="recipient", |
| tags=["social"] if intent == "social" else [intent], |
| weight=0.3, |
| ) |
| remember(npc, world.tick, f"You talked with {target.name}: {message}") |
| remember(target, world.tick, f"{npc.name} talked with you: {message}") |
| if has_message: |
| |
| _overhear_speech(world, npc, message, intent, exclude_ids={target.id}) |
| _log_event( |
| world, |
| "speech", |
| f"{npc.name} to {target.name}: {message}", |
| actor_id=npc.id, |
| target_id=target.id, |
| ) |
| return f"talking to {target.name}" |
|
|
| |
| add_episode( |
| npc, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"I said: {message}", |
| subject_kind="npc", |
| perspective="self", |
| tags=["social"] if intent == "social" else [intent], |
| weight=0.3, |
| ) |
| remember(npc, world.tick, f"You said: {message}") |
| heard_by = _overhear_speech(world, npc, message, intent, exclude_ids=set()) |
| if has_message: |
| _log_event(world, "speech", f"{npc.name}: {message}", actor_id=npc.id) |
| if heard_by: |
| return f"shouting (heard by {heard_by} nearby)" |
| return "shouting, but no one is close enough to hear" |
|
|
|
|
| def _emit_side_speech(world: WorldState, npc: Npc, message: str) -> int: |
| """Emit speech said ALONGSIDE another action (the side-channel `speak` field). |
| |
| Resolved as a shout heard by everyone within earshot, mirroring how the |
| dedicated speak action logs and remembers a line. Returns hearers count. |
| """ |
| text = message.strip() |
| if not text: |
| return 0 |
| add_episode( |
| npc, |
| world.tick, |
| "communicate", |
| npc.id, |
| f"I said: {text}", |
| subject_kind="npc", |
| perspective="self", |
| tags=["social"], |
| weight=0.3, |
| ) |
| remember(npc, world.tick, f"You said: {text}") |
| heard = _overhear_speech(world, npc, text, "social", exclude_ids=set()) |
| _log_event(world, "speech", f"{npc.name}: {text}", actor_id=npc.id) |
| return heard |
|
|
|
|
| def _overhear_speech( |
| world: WorldState, |
| speaker: Npc, |
| message: str, |
| intent: str, |
| *, |
| exclude_ids: set[str], |
| ) -> int: |
| """Record `message` in the memory/log of every NPC within earshot. |
| |
| Returns the number of NPCs who heard it. |
| """ |
| heard = 0 |
| for hearer in npcs_in_radius(world, speaker.position, SPEECH_RADIUS, exclude_id=speaker.id): |
| if hearer.id in exclude_ids: |
| continue |
| add_episode( |
| hearer, |
| world.tick, |
| "communicate", |
| speaker.id, |
| f"{speaker.name} said: {message}", |
| subject_kind="npc", |
| perspective="witness", |
| tags=["social"] if intent == "social" else [intent], |
| weight=0.2, |
| ) |
| remember(hearer, world.tick, f"{speaker.name} said: {message}") |
| heard += 1 |
| return heard |
|
|
|
|
| def _apply_build(npc: Npc, world: WorldState) -> str: |
| half_width = world.terrain.width / 2 |
| half_depth = world.terrain.depth / 2 |
|
|
| |
| |
| damaged = _nearest_damaged_house(world, npc.position) |
| if damaged is not None and npc.inventory_wood >= HOUSE_REPAIR_WOOD_COST: |
| if vec_distance(npc.position, damaged.position) > HOUSE_BUILD_RADIUS: |
| npc.position = move_toward( |
| npc.position, |
| damaged.position, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return f"rushing to repair {damaged.id}" |
| npc.inventory_wood -= HOUSE_REPAIR_WOOD_COST |
| before = damaged.hp |
| damaged.hp = min(damaged.max_hp, damaged.hp + HOUSE_REPAIR_PER_ACTION) |
| _earn_coins(npc, world, COINS_PER_BUILD_TICK, f"repairing {damaged.id}") |
| _log_event( |
| world, |
| "house_repaired", |
| f"{npc.name} repaired {damaged.id} (+{round(damaged.hp - before)} hp)", |
| severity="good", |
| actor_id=npc.id, |
| target_id=damaged.id, |
| ) |
| remember(npc, world.tick, f"You repaired {damaged.id}.") |
| return f"repairing {damaged.id}" |
|
|
| active_house = _house_by_id(world, npc.build_target_house_id or "") |
| if active_house is not None and active_house.state == "under_construction": |
| distance = vec_distance(npc.position, active_house.position) |
| if distance > HOUSE_BUILD_RADIUS: |
| npc.position = move_toward( |
| npc.position, |
| active_house.position, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return f"returning to build {active_house.id}" |
| active_house.build_progress += 1 |
| _earn_coins(npc, world, COINS_PER_BUILD_TICK, f"building {active_house.id}") |
| if npc.id not in active_house.owner_ids: |
| active_house.owner_ids.append(npc.id) |
| if active_house.build_progress >= 10: |
| active_house.state = "completed" |
| active_house.hp = active_house.max_hp |
| npc.build_target_house_id = None |
| npc.houses_built += 1 |
| world.houses_built += 1 |
| world.overseer_score += 2 |
| _earn_coins(npc, world, COINS_ON_BUILD_COMPLETE, f"completing {active_house.id}") |
| _record_world_event( |
| world, |
| "build_completed", |
| f"{npc.name} completed {active_house.id}", |
| active_house.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=6, |
| ) |
| _log_event( |
| world, |
| "build_completed", |
| f"{npc.name} completed {active_house.id}", |
| severity="good", |
| actor_id=npc.id, |
| target_id=active_house.id, |
| ) |
| remember(npc, world.tick, f"You completed {active_house.id}.") |
| return f"completed {active_house.id}" |
| return f"building {active_house.id}" |
|
|
| if npc.inventory_wood < 5: |
| return "needs wood to build" |
|
|
| ruin = _nearest_destroyed_house(world, npc.position) |
| if ruin is not None: |
| if vec_distance(npc.position, ruin.position) > HOUSE_BUILD_RADIUS: |
| npc.position = move_toward( |
| npc.position, |
| ruin.position, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return f"heading to rebuild {ruin.id}" |
| npc.inventory_wood -= 5 |
| ruin.state = "under_construction" |
| ruin.build_progress = 1 |
| if npc.id not in ruin.owner_ids: |
| ruin.owner_ids.append(npc.id) |
| npc.build_target_house_id = ruin.id |
| _log_event( |
| world, |
| "build_started", |
| f"{npc.name} started rebuilding {ruin.id}", |
| severity="good", |
| actor_id=npc.id, |
| target_id=ruin.id, |
| ) |
| remember(npc, world.tick, f"You started rebuilding {ruin.id}.") |
| return f"rebuilding {ruin.id}" |
|
|
| if not _can_start_house(world, npc.position): |
| site = _preferred_build_site(world, npc) |
| npc.position = move_toward( |
| npc.position, |
| site, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return "moving to build site" |
|
|
| npc.inventory_wood -= 5 |
| house = House( |
| id=f"house_{_next_house_index(world):03d}", |
| position=npc.position, |
| owner_ids=[npc.id], |
| hp=60.0, |
| max_hp=60.0, |
| state="under_construction", |
| build_progress=1, |
| capacity=3, |
| ) |
| world.houses.append(house) |
| npc.build_target_house_id = house.id |
| _record_world_event( |
| world, |
| "build_started", |
| f"{npc.name} started {house.id}", |
| house.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=5, |
| ) |
| _log_event( |
| world, |
| "build_started", |
| f"{npc.name} started {house.id}", |
| severity="good", |
| actor_id=npc.id, |
| target_id=house.id, |
| ) |
| remember(npc, world.tick, f"You started building {house.id}.") |
| return f"building {house.id}" |
|
|
|
|
| def _can_craft_cannon(world: WorldState, npc: Npc) -> bool: |
| country = country_for_npc(world, npc) |
| if country is None or country.ruler_id != npc.id or country.cannon is not None: |
| return False |
| if len(country.citizen_ids) < _env_int("CANNON_MIN_POPULATION", DEFAULT_CANNON_MIN_POPULATION): |
| return False |
| treasury = country.treasury.resources if country.treasury else {} |
| return ( |
| treasury.get("coins", 0) >= _env_int("CANNON_TREASURY_COST", DEFAULT_CANNON_TREASURY_COST) |
| and treasury.get("wood", 0) >= _env_int("CANNON_WOOD_COST", DEFAULT_CANNON_WOOD_COST) |
| ) |
|
|
|
|
| def _can_assign_cannon_operator( |
| world: WorldState, |
| npc: Npc, |
| directive: NpcDirective | None, |
| ) -> bool: |
| country = country_for_npc(world, npc) |
| target_id = _param_str(directive, "npc_id") or (directive.target_npc_id if directive else None) |
| target = _npc_by_id(world, target_id or "") |
| return ( |
| country is not None |
| and country.ruler_id == npc.id |
| and country.cannon is not None |
| and target is not None |
| and target.country_id == country.id |
| ) |
|
|
|
|
| def _can_fire_cannon(world: WorldState, npc: Npc, directive: NpcDirective | None) -> bool: |
| country = country_for_npc(world, npc) |
| if country is None or country.cannon is None: |
| return False |
| return country.cannon.operator_id == npc.id and world.tick >= country.cannon.cooldown_until_tick |
|
|
|
|
| def _apply_craft(npc: Npc, world: WorldState, directive: NpcDirective | None) -> str: |
| if _param_str(directive, "recipe_id") not in {None, "cannon"}: |
| return "unknown recipe" |
| if not _can_craft_cannon(world, npc): |
| return "cannot craft cannon yet" |
| country = country_for_npc(world, npc) |
| assert country is not None and country.treasury is not None |
| treasury = country.treasury.resources |
| coin_cost = _env_int("CANNON_TREASURY_COST", DEFAULT_CANNON_TREASURY_COST) |
| wood_cost = _env_int("CANNON_WOOD_COST", DEFAULT_CANNON_WOOD_COST) |
| hp_tax = _env_int("CANNON_HP_TAX_PER_CITIZEN", DEFAULT_CANNON_HP_TAX_PER_CITIZEN) |
| treasury["coins"] = treasury.get("coins", 0) - coin_cost |
| treasury["wood"] = treasury.get("wood", 0) - wood_cost |
| for citizen in world.living_npcs(): |
| if citizen.country_id == country.id: |
| citizen.health = max(1, citizen.health - hp_tax) |
| country.cannon = CannonState( |
| id=f"cannon_{country.id}", |
| position=country.treasury.position, |
| damage=_env_int("CANNON_DAMAGE", DEFAULT_CANNON_DAMAGE), |
| radius=_env_int("CANNON_RADIUS", DEFAULT_CANNON_RADIUS), |
| ) |
| _log_event( |
| world, |
| "cannon_crafted", |
| f"{country.name} crafted {country.cannon.id}", |
| severity="good", |
| actor_id=npc.id, |
| object_id=country.cannon.id, |
| ) |
| return f"crafting {country.cannon.id}" |
|
|
|
|
| def _apply_assign_cannon_operator( |
| npc: Npc, |
| world: WorldState, |
| directive: NpcDirective | None, |
| ) -> str: |
| if not _can_assign_cannon_operator(world, npc, directive): |
| return "cannot assign cannon operator" |
| country = country_for_npc(world, npc) |
| assert country is not None and country.cannon is not None |
| target_id = _param_str(directive, "npc_id") or directive.target_npc_id |
| country.cannon.operator_id = target_id |
| _refresh_country_citizens(world, world.tick) |
| _log_event( |
| world, |
| "cannon_operator_assigned", |
| f"{country.name} assigned {target_id} to {country.cannon.id}", |
| severity="good", |
| actor_id=npc.id, |
| target_id=target_id, |
| object_id=country.cannon.id, |
| ) |
| return f"assigning cannon operator {target_id}" |
|
|
|
|
| def _apply_fire_cannon(npc: Npc, world: WorldState, directive: NpcDirective | None) -> str: |
| if not _can_fire_cannon(world, npc, directive): |
| return "cannot fire cannon" |
| country = country_for_npc(world, npc) |
| assert country is not None and country.cannon is not None |
| x = _param_float(directive, "x") |
| z = _param_float(directive, "z") |
| if x is None or z is None: |
| return "missing cannon target" |
| target = Vec3(x=x, y=0.0, z=z) |
| damaged: list[str] = [] |
| for target_npc in list(world.living_npcs()): |
| if vec_distance(target_npc.position, target) > country.cannon.radius: |
| continue |
| target_npc.health -= country.cannon.damage |
| damaged.append(target_npc.id) |
| if target_npc.health <= 0: |
| _kill_npc(world, target_npc, cause="cannon", actor_id=npc.id) |
| cooldown = _env_int("CANNON_COOLDOWN_TICKS", DEFAULT_CANNON_COOLDOWN_TICKS) |
| country.cannon.cooldown_until_tick = world.tick + cooldown |
| _log_event( |
| world, |
| "cannon_fired", |
| f"{npc.name} fired {country.cannon.id} at ({x:g}, {z:g}) hitting {len(damaged)} targets", |
| severity="danger" if damaged else "warning", |
| actor_id=npc.id, |
| object_id=country.cannon.id, |
| ) |
| return f"firing cannon at {x:g}, {z:g}" |
|
|
|
|
| def _param_str(directive: NpcDirective | None, key: str) -> str | None: |
| if directive is None or not isinstance(directive.params, dict): |
| return None |
| value = directive.params.get(key) |
| return value if isinstance(value, str) else None |
|
|
|
|
| def _param_float(directive: NpcDirective | None, key: str) -> float | None: |
| if directive is None or not isinstance(directive.params, dict): |
| return None |
| value = directive.params.get(key) |
| if isinstance(value, int | float) and not isinstance(value, bool): |
| return float(value) |
| return None |
|
|
|
|
| def _patrol_target(world: WorldState, npc: Npc) -> Vec3 | None: |
| threat = _nearest_village_threat(world, npc) |
| if threat is not None: |
| return threat.position |
| houses = completed_houses(world) |
| if not houses: |
| return Vec3(x=0.0, y=0.0, z=0.0) |
| houses = sorted(houses, key=lambda house: house.id) |
| if vec_distance(npc.position, houses[npc.patrol_index % len(houses)].position) <= NPC_MOVE_STEP: |
| npc.patrol_index = (npc.patrol_index + 1) % len(houses) |
| return houses[npc.patrol_index % len(houses)].position |
|
|
|
|
| def _nearest_village_threat(world: WorldState, npc: Npc) -> Beast | None: |
| candidates = [ |
| beast |
| for beast in world.beasts |
| if beast.state != "dead" |
| and ( |
| any(vec_distance(beast.position, house.position) <= VILLAGE_THREAT_RADIUS for house in world.houses) |
| or any( |
| vec_distance(beast.position, other.position) <= VILLAGE_THREAT_RADIUS |
| for other in world.living_npcs() |
| ) |
| ) |
| ] |
| if not candidates: |
| return nearest_beast(world, npc.position) |
| return min(candidates, key=lambda beast: (vec_distance(npc.position, beast.position), beast.id)) |
|
|
|
|
| def _npc_damage_against_beast(npc: Npc) -> float: |
| damage = BASE_NPC_DAMAGE + (WEAPON_DAMAGE_BONUS if npc.inventory_weapon > 0 else 0) |
| role = normalize_role(npc.role) |
| if role == "guard": |
| return damage * GUARD_DAMAGE_MULTIPLIER |
| if role in {"gatherer", "builder"}: |
| return damage * WEAK_DAMAGE_MULTIPLIER |
| return float(damage) |
|
|
|
|
| def _melee_target_npc( |
| world: WorldState, |
| npc: Npc, |
| directive: NpcDirective | None, |
| ) -> Npc | None: |
| """The living NPC this attack directive explicitly targets (any nation). |
| |
| Whether to strike — and whom, even a countryman — is the model's call, not |
| the engine's. We only require an explicit, living target other than self; we |
| never auto-pick someone to attack, so allies are not hit by accident. |
| """ |
| if directive is None: |
| return None |
| target_id = directive.target_npc_id or directive.target_entity_id |
| if not target_id: |
| return None |
| target = _npc_by_id(world, target_id) |
| if target is None or target.id == npc.id or not is_alive(target): |
| return None |
| return target |
|
|
|
|
| def _apply_npc_attack(npc: Npc, world: WorldState, target: Npc) -> str: |
| """Melee strike against a rival NPC: close in if out of reach, else wound them.""" |
| half_width = world.terrain.width / 2 |
| half_depth = world.terrain.depth / 2 |
| npc.focus_target_id = target.id |
| if vec_distance(npc.position, target.position) > NPC_MELEE_REACH: |
| npc.position = move_toward( |
| npc.position, |
| target.position, |
| NPC_MOVE_STEP, |
| half_width=half_width, |
| half_depth=half_depth, |
| ) |
| return f"charging {target.name}" |
|
|
| damage = _npc_damage_against_beast(npc) |
| target.health -= damage |
| target.fear = min(100.0, max(target.fear, FEAR_ON_ATTACK)) |
| npc.relationships[target.id] = -1.0 |
| add_episode( |
| npc, |
| world.tick, |
| "attack", |
| npc.id, |
| f"I attacked {target.name} for {damage:g} damage", |
| target_id=target.id, |
| subject_kind="npc", |
| perspective="self", |
| tags=["combat", "raid"], |
| weight=0.9, |
| ) |
| add_episode( |
| target, |
| world.tick, |
| "attack", |
| npc.id, |
| f"{npc.name} attacked me", |
| target_id=target.id, |
| subject_kind="npc", |
| perspective="recipient", |
| tags=["danger", "combat", "betrayal"], |
| weight=1.0, |
| ) |
| remember(npc, world.tick, f"You attacked {target.name}.") |
| remember(target, world.tick, f"{npc.name} attacked you at tick {world.tick}.") |
| _record_world_event( |
| world, |
| "npc_attack", |
| f"{npc.name} attacked {target.name}", |
| target.position, |
| radius=WITNESS_RADIUS, |
| duration_ticks=4, |
| ) |
| _log_event( |
| world, |
| "npc_attack", |
| f"{npc.name} attacked {target.name} for {damage:g}", |
| severity="danger", |
| actor_id=npc.id, |
| target_id=target.id, |
| ) |
| for witness in npcs_in_radius(world, target.position, WITNESS_RADIUS, exclude_id=target.id): |
| if witness.id == npc.id: |
| continue |
| witness.fear = min(100.0, witness.fear + FEAR_ON_WITNESS) |
| add_episode( |
| witness, |
| world.tick, |
| "attack", |
| npc.id, |
| f"{npc.name} attacked {target.name} nearby", |
| target_id=target.id, |
| subject_kind="npc", |
| perspective="witness", |
| tags=["danger", "combat"], |
| weight=0.8, |
| ) |
| if target.health <= 0: |
| _kill_npc(world, target, cause="slain", actor_id=npc.id) |
| return f"killed {target.name}" |
| return f"attacking {target.name}" |
|
|
|
|
| def _house_by_id(world: WorldState, house_id: str) -> House | None: |
| if not house_id: |
| return None |
| return next((house for house in world.houses if house.id == house_id), None) |
|
|
|
|
| def _nearest_destroyed_house(world: WorldState, position: Vec3) -> House | None: |
| ruins = [house for house in world.houses if house.state == "destroyed"] |
| if not ruins: |
| return None |
| return min(ruins, key=lambda house: (vec_distance(position, house.position), house.id)) |
|
|
|
|
| def _nearest_damaged_house(world: WorldState, position: Vec3) -> House | None: |
| """Nearest standing home that has taken damage but is not yet destroyed.""" |
| damaged = [ |
| house |
| for house in world.houses |
| if house.state == "completed" and 0 < house.hp < house.max_hp |
| ] |
| if not damaged: |
| return None |
| return min(damaged, key=lambda house: (vec_distance(position, house.position), house.id)) |
|
|
|
|
| def _can_start_house(world: WorldState, position: Vec3) -> bool: |
| return all(vec_distance(position, house.position) >= HOUSE_MIN_DISTANCE for house in world.houses) |
|
|
|
|
| def _preferred_build_site(world: WorldState, npc: Npc) -> Vec3: |
| half_width = world.terrain.width / 2 |
| half_depth = world.terrain.depth / 2 |
| angle = (len(world.houses) * 1.61803398875) % (2 * math.pi) |
| center = nearest_house(world, npc.position, completed_only=False) |
| origin = center.position if center is not None else Vec3(x=0.0, y=0.0, z=0.0) |
| return Vec3( |
| x=round(_clamp(origin.x + math.cos(angle) * (HOUSE_MIN_DISTANCE + TILE), -half_width, half_width), 3), |
| y=0.0, |
| z=round(_clamp(origin.z + math.sin(angle) * (HOUSE_MIN_DISTANCE + TILE), -half_depth, half_depth), 3), |
| ) |
|
|
|
|
| def _next_house_index(world: WorldState) -> int: |
| highest = 0 |
| for house in world.houses: |
| suffix = house.id.removeprefix("house_") |
| if suffix.isdigit(): |
| highest = max(highest, int(suffix)) |
| return highest + 1 |
|
|
|
|
| def resolve_call_for_help(caller: Npc, world: WorldState) -> list[Npc]: |
| helpers: list[Npc] = [] |
| for helper in npcs_in_radius(world, caller.position, HELP_RADIUS, exclude_id=caller.id): |
| if helper.inventory_weapon <= 0: |
| continue |
| if helper.relationships.get(caller.id, 0.0) <= 0.3: |
| continue |
| helper.survival_goal = "help_ally" |
| helper.help_target_id = caller.id |
| add_episode( |
| helper, |
| world.tick, |
| "communicate", |
| caller.id, |
| f"{caller.name} called for help", |
| subject_kind="npc", |
| perspective="recipient", |
| tags=["help", "danger"], |
| weight=0.6, |
| ) |
| helpers.append(helper) |
| return helpers |
|
|
|
|
| def _resource_from_directive( |
| world: WorldState, |
| directive: NpcDirective | None, |
| npc: Npc, |
| *, |
| max_dist: float | None = None, |
| ) -> ResourceNode | None: |
| if directive is not None and directive.resource_id: |
| node = next((node for node in world.resource_nodes if node.id == directive.resource_id), None) |
| if node is not None and (max_dist is None or vec_distance(npc.position, node.position) <= max_dist): |
| return node |
| return nearest_resource_for_npc(world, npc, max_dist=max_dist) |
|
|
|
|
| def _beast_from_directive( |
| world: WorldState, |
| npc: Npc, |
| directive: NpcDirective | None, |
| ) -> Beast | None: |
| if directive is not None and directive.target_entity_id: |
| beast = next( |
| ( |
| beast |
| for beast in world.beasts |
| if beast.id == directive.target_entity_id and beast.state != "dead" |
| ), |
| None, |
| ) |
| if beast is not None: |
| return beast |
| return nearest_beast(world, npc.position) |
|
|
|
|
| def _target_npc_from_directive( |
| world: WorldState, |
| npc: Npc, |
| directive: NpcDirective | None, |
| *, |
| max_dist: float | None = None, |
| ) -> Npc | None: |
| if directive is None or directive.target_npc_id is None: |
| return None |
| target = _npc_by_id(world, directive.target_npc_id) |
| if target is None or target.id == npc.id or not is_alive(target): |
| return None |
| if max_dist is not None and vec_distance(npc.position, target.position) > max_dist: |
| return None |
| return target |
|
|
|
|
| def _transfer_partner(world: WorldState, npc: Npc, directive: NpcDirective | None) -> Npc | None: |
| partner = _target_npc_from_directive(world, npc, directive, max_dist=TRADE_RADIUS) |
| if partner is not None: |
| return partner |
| return _neediest_food_neighbor(world, npc) |
|
|
|
|
| def _treasury_from_directive( |
| world: WorldState, |
| directive: NpcDirective | None, |
| ) -> tuple[CountryState, TreasuryState] | None: |
| target_id = directive.target_entity_id if directive is not None else None |
| if target_id is None: |
| return None |
| for country in world.countries: |
| if country.treasury is not None and country.treasury.id == target_id: |
| return country, country.treasury |
| return None |
|
|
|
|
| def _apply_treasury_transfer( |
| npc: Npc, |
| world: WorldState, |
| treasury_ref: tuple[CountryState, TreasuryState], |
| directive: NpcDirective | None, |
| ) -> str: |
| country, treasury = treasury_ref |
| resource_type = _resource_type_from_directive(directive, default="coins") |
| amount = _amount_from_directive(directive, default=1) |
| take = bool(directive and directive.take) |
| position = treasury.position |
| resources = treasury.resources |
| if vec_distance(npc.position, position) > TRADE_RADIUS: |
| npc.position = move_toward( |
| npc.position, |
| position, |
| NPC_MOVE_STEP, |
| half_width=world.terrain.width / 2, |
| half_depth=world.terrain.depth / 2, |
| ) |
| return "approaching treasury" |
| if take: |
| if npc.country_id == country.id: |
| return "guarding own treasury" |
| available = resources.get(resource_type, 0) |
| if available <= 0: |
| return "found empty treasury" |
| moved = min(amount, available) |
| resources[resource_type] = available - moved |
| _add_to_inventory_amount(npc, resource_type, moved) |
| _log_event( |
| world, |
| "treasury_stolen", |
| f"{npc.name} stole {moved} {resource_type} from {country.name}", |
| severity="warning", |
| actor_id=npc.id, |
| object_id=treasury.id, |
| ) |
| |
| |
| for member in world.living_npcs(): |
| if member.country_id != country.id or member.id == npc.id: |
| continue |
| add_episode( |
| member, |
| world.tick, |
| "steal", |
| npc.id, |
| f"{npc.name} stole {moved} {resource_type} from our treasury", |
| target_id=treasury.id, |
| object_id=resource_type, |
| subject_kind="npc", |
| perspective="recipient", |
| tags=[resource_type, "betrayal", "treasury"], |
| weight=0.9, |
| ) |
| remember( |
| member, |
| world.tick, |
| f"{npc.name} stole {moved} {resource_type} from our treasury.", |
| ) |
| return f"stealing {resource_type} from {country.name}" |
| if npc.country_id != country.id: |
| return "refusing to fund foreign treasury" |
| available = _inventory_amount(npc, resource_type) |
| if available <= 0: |
| return f"no {resource_type} to deposit" |
| moved = min(amount, available) |
| _remove_from_inventory(npc, resource_type, moved) |
| resources[resource_type] = resources.get(resource_type, 0) + moved |
| npc.resources_transferred += moved |
| _log_event( |
| world, |
| "treasury_deposit", |
| f"{npc.name} deposited {moved} {resource_type} into {country.name} treasury", |
| severity="good", |
| actor_id=npc.id, |
| object_id=treasury.id, |
| ) |
| return f"depositing {resource_type} into {country.name} treasury" |
|
|
|
|
| def _resource_type_from_directive(directive: NpcDirective | None, *, default: str) -> str: |
| if directive is not None and directive.resource_type in {"food", "herbs", "wood", "weapon", "coins"}: |
| return directive.resource_type |
| return default |
|
|
|
|
| def _amount_from_directive(directive: NpcDirective | None, *, default: int) -> int: |
| if directive is not None and directive.amount is not None: |
| return max(1, min(20, directive.amount)) |
| return default |
|
|
|
|
| def _communication_intent(directive: NpcDirective | None, npc: Npc) -> str: |
| raw = None |
| if directive is not None: |
| raw = directive.communication_intent or directive.intent |
| if isinstance(raw, str): |
| if "help_request" in raw or "call_for_help" in raw: |
| return "help_request" |
| if "survive_threat" in raw: |
| return "help_request" |
| if "trade_request" in raw or "request_trade" in raw: |
| return "trade_request" |
| if "warning" in raw: |
| return "warning" |
| if "social" in raw or "talk" in raw: |
| return "social" |
| if npc.fear > 45: |
| return "help_request" |
| if npc.hunger > STARVATION_THRESHOLD: |
| return "trade_request" |
| return "social" |
|
|
|
|
| def _message_for_intent(intent: str) -> str: |
| if intent == "help_request": |
| return "Help! I am in danger!" |
| if intent == "trade_request": |
| return "Can you spare food?" |
| if intent == "warning": |
| return "Stay alert." |
| return "How are you holding up?" |
|
|
|
|
| def _earn_coins(npc: Npc, world: WorldState, amount: int, reason: str) -> None: |
| """Pay an NPC a coin wage for productive work and log it as income.""" |
| if amount <= 0: |
| return |
| npc.inventory_coins += amount |
| _log_event( |
| world, |
| "coins_earned", |
| f"{npc.name} earned {amount} coins by {reason}", |
| severity="good", |
| actor_id=npc.id, |
| ) |
|
|
|
|
| def _add_to_inventory(npc: Npc, resource_type: str) -> None: |
| _add_to_inventory_amount(npc, resource_type, 1) |
|
|
|
|
| def _add_to_inventory_amount(npc: Npc, resource_type: str, amount: int) -> None: |
| if resource_type == "food": |
| npc.inventory_food += amount |
| elif resource_type == "herbs": |
| npc.inventory_herbs += amount |
| elif resource_type == "wood": |
| npc.inventory_wood += amount |
| elif resource_type == "weapon": |
| npc.inventory_weapon += amount |
| elif resource_type == "coins": |
| npc.inventory_coins += amount |
|
|
|
|
| def _remove_from_inventory(npc: Npc, resource_type: str, amount: int) -> None: |
| if resource_type == "food": |
| npc.inventory_food = max(0, npc.inventory_food - amount) |
| elif resource_type == "herbs": |
| npc.inventory_herbs = max(0, npc.inventory_herbs - amount) |
| elif resource_type == "wood": |
| npc.inventory_wood = max(0, npc.inventory_wood - amount) |
| elif resource_type == "weapon": |
| npc.inventory_weapon = max(0, npc.inventory_weapon - amount) |
| elif resource_type == "coins": |
| npc.inventory_coins = max(0, npc.inventory_coins - amount) |
|
|
|
|
| def _inventory_amount(npc: Npc, resource_type: str) -> int: |
| if resource_type == "food": |
| return npc.inventory_food |
| if resource_type == "herbs": |
| return npc.inventory_herbs |
| if resource_type == "wood": |
| return npc.inventory_wood |
| if resource_type == "weapon": |
| return npc.inventory_weapon |
| if resource_type == "coins": |
| return npc.inventory_coins |
| return 0 |
|
|
|
|
| def _resource_destination(world: WorldState, npc: Npc, action: str) -> Vec3 | None: |
| if normalize_role(npc.role) == "builder": |
| resource_type: str | None = "wood" |
| elif action == "find_food": |
| resource_type: str | None = "food" |
| elif action == "find_herbs": |
| resource_type = "herbs" |
| else: |
| resource_type = None |
| node = nearest_resource_for_npc(world, npc, resource_type=resource_type) |
| if node is None: |
| node = nearest_resource_for_npc(world, npc) |
| return node.position if node is not None else None |
|
|
|
|
| def _spread_resource( |
| world: WorldState, |
| npc: Npc, |
| index: int, |
| *, |
| resource_type: str | None = None, |
| ) -> ResourceNode | None: |
| """Pick one of the nearest non-empty nodes, fanned out by NPC index. |
| |
| Spreading foragers across several nearby nodes stops the whole settlement from |
| piling onto a single node and then starving together. |
| """ |
| if normalize_role(npc.role) == "builder": |
| resource_type = "wood" |
| nodes = sorted( |
| ( |
| node |
| for node in world.resource_nodes |
| if node.amount > 0 and (resource_type is None or node.resource_type == resource_type) |
| ), |
| key=lambda node: (vec_distance(npc.position, node.position), node.id), |
| ) |
| if not nodes and resource_type is not None and normalize_role(npc.role) != "builder": |
| nodes = sorted( |
| (node for node in world.resource_nodes if node.amount > 0), |
| key=lambda node: (vec_distance(npc.position, node.position), node.id), |
| ) |
| if not nodes: |
| return None |
| return nodes[index % min(SPREAD_NODE_CHOICES, len(nodes))] |
|
|
|
|
| def _neediest_food_neighbor(world: WorldState, npc: Npc) -> Npc | None: |
| candidates = [ |
| other |
| for other in world.living_npcs() |
| if other.id != npc.id |
| and vec_distance(npc.position, other.position) <= TRADE_RADIUS |
| ] |
| if not candidates: |
| return None |
| return min(candidates, key=lambda other: (other.inventory_food, -other.hunger, other.id)) |
|
|
|
|
| def _small_talk_line(npc: Npc, partner: Npc) -> str: |
| if npc.fear > 30: |
| return "stay alert, the beast is near" |
| if npc.hunger > 60: |
| return "I'm getting hungry" |
| if npc.inventory_food >= SHARE_FOOD_SURPLUS: |
| return "I have food to spare if you need it" |
| if npc.inventory_weapon > 0: |
| return "keep close, I'm armed" |
| return "how are you holding up?" |
|
|
|
|
| def _npc_by_id(world: WorldState, npc_id: str) -> Npc | None: |
| return next((candidate for candidate in world.living_npcs() if candidate.id == npc_id), None) |
|
|
|
|
| def _adjust_relationship(npc: Npc, target_id: str, delta: float) -> None: |
| current = -1.0 if target_id.startswith("beast") else npc.relationships.get(target_id, 0.0) |
| npc.relationships[target_id] = round(_clamp(current + delta, -1.0, 1.0), 3) |
|
|
|
|
| def _has_trusted_ally_nearby(world: WorldState, npc: Npc) -> bool: |
| for other in npcs_in_radius(world, npc.position, ALLY_RADIUS, exclude_id=npc.id): |
| trust = npc.relationships.get(other.id, 0.0) |
| if trust > 0.3: |
| return True |
| return False |
|
|
|
|
| def _relationship_label(trust: float) -> str: |
| if trust >= 0.5: |
| return "ally" |
| if trust <= -0.3: |
| return "enemy" |
| return "neutral" |
|
|
|
|
| def _memory_tag(episode: MemoryEpisode) -> str: |
| if "danger" in episode.tags or episode.kind in {"attack", "steal"}: |
| return "high threat" if episode.emotional_weight >= 0.8 else "threat" |
| if "help" in episode.tags or episode.kind == "transfer": |
| return "ally" |
| if "food" in episode.tags: |
| return "food" |
| return "memory" |
|
|
|
|
| def _direction(origin: Vec3, target: Vec3) -> str: |
| |
| dx = target.x - origin.x |
| dz = target.z - origin.z |
| if abs(dx) >= abs(dz): |
| return "WEST" if dx >= 0 else "EAST" |
| return "NORTH" if dz >= 0 else "SOUTH" |
|
|
|
|
| def _tiles(distance: float) -> float: |
| return distance / TILE |
|
|
|
|
| def _clamp(value: float, minimum: float, maximum: float) -> float: |
| return min(max(value, minimum), maximum) |
|
|
|
|
| |
| |
| |
| def is_survival_world(world: WorldState) -> bool: |
| return bool(world.beasts or world.resource_nodes or world.houses) |
|
|
|
|
| def survival_directive_for(world: WorldState, npc: Npc, next_tick: int) -> NpcDirective: |
| """Deterministic survival directive for a single NPC (used as a safe fallback).""" |
| goal = select_goal(npc, world) |
| index = next((i for i, candidate in enumerate(world.npcs) if candidate.id == npc.id), 0) |
| return _plan_directive(world, npc, goal, next_tick, index) |
|
|
|
|
| def propose_survival_tick(world: WorldState, next_tick: int) -> TickPlan: |
| """Deterministic survival plan: one survival action per living NPC.""" |
| directives: list[NpcDirective] = [] |
| for index, npc in enumerate(world.living_npcs()): |
| goal = select_goal(npc, world) |
| directives.append(_plan_directive(world, npc, goal, next_tick, index)) |
| return TickPlan(source="survival", directives=directives) |
|
|
|
|
| def apply_survival_plan( |
| world: WorldState, |
| next_tick: int, |
| plan: TickPlan, |
| ) -> list[dict[str, object]]: |
| """Resolve a survival plan onto the live world. Returns debug traces.""" |
| debug: list[dict[str, object]] = [] |
| npcs_by_id = {npc.id: npc for npc in world.npcs} |
|
|
| for directive in plan.directives: |
| npc = npcs_by_id.get(directive.npc_id) |
| if npc is None: |
| continue |
| if not is_alive(npc): |
| npc.intention = "dead" |
| continue |
| requested_effect = _canonical_action(_verb_for_action(npc, directive.action, world, directive)) |
| action = validate_survival_action(npc, directive.action, world, directive) |
| summary = apply_action_effects( |
| npc, |
| action, |
| world, |
| destination=directive.target, |
| directive=directive, |
| ) |
| npc.intention = summary |
| |
| |
| if directive.speech and directive.action != "speak": |
| _emit_side_speech(world, npc, directive.speech) |
| |
| |
| if directive.reasoning: |
| npc.last_reasoning = directive.reasoning |
| npc.last_reasoning_tick = next_tick |
| if directive.conversation_user and directive.conversation_assistant: |
| npc.conversation_context.add_turn( |
| directive.conversation_user, |
| directive.conversation_assistant, |
| ) |
| if directive.memory: |
| remember(npc, next_tick, directive.memory) |
| |
| |
| |
| |
| if directive.memory_summary: |
| cleaned = strip_tick_entries(directive.memory_summary) |
| if cleaned: |
| npc.memory_summary = cleaned |
| npc.memory_summary_self_authored = True |
| if next_tick > 0 and next_tick % 10 == 0: |
| compress_to_facts(npc) |
| debug.append( |
| { |
| "npc_id": npc.id, |
| "goal": npc.survival_goal, |
| "requested_action": directive.action, |
| "requested_effect": requested_effect, |
| "action": action, |
| "summary": summary, |
| "validator_verdict": _survival_validator_verdict( |
| requested_action=str(directive.action), |
| requested_effect=requested_effect, |
| resolved_action=action, |
| ), |
| } |
| ) |
|
|
| for npc in world.npcs: |
| if not is_alive(npc): |
| npc.intention = "dead" |
|
|
| return debug |
|
|
|
|
| def _survival_validator_verdict( |
| *, |
| requested_action: str, |
| requested_effect: str, |
| resolved_action: str, |
| ) -> dict[str, object]: |
| if requested_effect == resolved_action: |
| return { |
| "status": "accepted", |
| "requested": requested_action, |
| "requested_effect": requested_effect, |
| "resolved": resolved_action, |
| "reason": "accepted", |
| } |
| if resolved_action == "idle" and requested_effect != "idle": |
| return { |
| "status": "rejected", |
| "requested": requested_action, |
| "requested_effect": requested_effect, |
| "resolved": resolved_action, |
| "reason": "invalid or impossible survival action", |
| } |
| return { |
| "status": "repaired", |
| "requested": requested_action, |
| "requested_effect": requested_effect, |
| "resolved": resolved_action, |
| "reason": "engine validation repaired action", |
| } |
|
|
|
|
| def _plan_directive( |
| world: WorldState, |
| npc: Npc, |
| goal: str, |
| next_tick: int, |
| index: int, |
| ) -> NpcDirective: |
| npc.survival_goal = goal |
|
|
| def directive( |
| action: str, |
| *, |
| target: Vec3 | None = None, |
| target_npc_id: str | None = None, |
| target_entity_id: str | None = None, |
| resource_id: str | None = None, |
| resource_type: str | None = None, |
| amount: int | None = None, |
| communication_intent: str | None = None, |
| message: str | None = None, |
| params: dict[str, object] | None = None, |
| away: bool = False, |
| ) -> NpcDirective: |
| public_action = action |
| public_use_type: str | None = None |
| public_take = False |
| if action in {"strike", "attack"}: |
| public_action = "attack" |
| elif action in {"gather", "consume", "eat", "heal", "build", "repair"}: |
| public_action = "use" |
| public_use_type = { |
| "consume": "eat", |
| "eat": "eat", |
| "heal": "heal", |
| "gather": "gather", |
| "build": "build", |
| "repair": "repair", |
| }[action] |
| if action in {"consume", "eat"} and resource_type is None: |
| resource_type = "food" |
| if action == "heal" and resource_type is None: |
| resource_type = "herbs" |
| elif action in {"craft", "assign_cannon_operator", "fire"}: |
| public_action = "use" |
| public_use_type = action |
| elif action in {"communicate", "trade", "request_trade"}: |
| public_action = "speak" |
| elif action == "steal": |
| public_action = "transfer" |
| public_take = True |
| elif action in { |
| "go_home", |
| "patrol", |
| "flee", |
| "move_to_resource", |
| "move_to", |
| "find_food", |
| "find_herbs", |
| "wander", |
| }: |
| public_action = "move" |
| if action == "go_home" and target is None: |
| home = nearest_house(world, npc.position) |
| target = home.position if home is not None else None |
| elif action == "patrol" and target is None: |
| target = _patrol_target(world, npc) |
| away = away or action == "flee" |
| return NpcDirective( |
| npc_id=npc.id, |
| action=public_action, |
| target=target, |
| target_npc_id=target_npc_id, |
| target_entity_id=target_entity_id, |
| resource_id=resource_id, |
| resource_type=resource_type, |
| use_type=public_use_type, |
| params=params, |
| amount=amount, |
| communication_intent=communication_intent, |
| message=message, |
| away=away, |
| take=public_take, |
| intent=f"survival:{goal}", |
| ) |
|
|
| role = normalize_role(npc.role) |
| election = active_election_for_npc(world, npc, tick=next_tick) |
| if election is not None and npc.id not in election.votes and election.candidate_ids: |
| candidate_id = _fallback_vote(world, election, voter_id=npc.id) |
| return directive("vote", target_npc_id=candidate_id) |
| country = country_for_npc(world, npc) |
| if country is not None and country.ruler_id == npc.id: |
| if country.cannon is None and _can_craft_cannon(world, npc): |
| return directive("craft", params={"recipe_id": "cannon"}) |
| if country.cannon is not None and country.cannon.operator_id is None: |
| operator = next( |
| ( |
| citizen |
| for citizen in world.living_npcs() |
| if citizen.country_id == country.id and citizen.id != npc.id |
| ), |
| npc, |
| ) |
| return directive("assign_cannon_operator", params={"npc_id": operator.id}) |
| if ( |
| country is not None |
| and country.cannon is not None |
| and country.cannon.operator_id == npc.id |
| and next_tick >= country.cannon.cooldown_until_tick |
| ): |
| target = next( |
| (other for other in world.living_npcs() if other.country_id != country.id), |
| None, |
| ) |
| if target is not None: |
| return directive( |
| "fire", |
| target=country.cannon.position, |
| target_npc_id=target.id, |
| params={"x": target.position.x, "z": target.position.z}, |
| ) |
|
|
| if goal == "obey_directive": |
| text = (npc.god_directive or "").lower() |
| target = _directive_named_target(world, npc, text) |
| if any(word in text for word in ("attack", "kill", "fight", "hunt", "strike")): |
| if target is not None: |
| return directive("strike", target_npc_id=target.id) |
| if "beast" in text or "monster" in text: |
| beast = nearest_beast(world, npc.position) |
| if beast is not None: |
| return directive("strike", target=beast.position) |
| return directive("strike") |
| if any(word in text for word in ("home", "shelter", "hide", "safe")): |
| return directive("go_home") |
| if any(word in text for word in ("build", "repair", "house")): |
| return directive("build") |
| if any(word in text for word in ("wood", "food", "herb", "gather", "resource")): |
| resource_type = "wood" if "wood" in text else "food" if "food" in text else "herbs" if "herb" in text else None |
| node = _spread_resource(world, npc, index, resource_type=resource_type) |
| if node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS: |
| return directive("use", resource_id=node.id, resource_type=resource_type) |
| if node is not None: |
| return directive("move", target=node.position, resource_id=node.id, resource_type=resource_type) |
| if any(word in text for word in ("help", "warn", "talk", "speak", "tell")): |
| partner = target or nearest_alive_npc(world, npc, max_dist=TALK_RADIUS) |
| if partner is not None: |
| return directive("speak", target_npc_id=partner.id, communication_intent="directive") |
| if any(word in text for word in ("patrol", "guard", "watch")): |
| return directive("patrol") |
| return directive("idle") |
|
|
| if goal == "engage_threat": |
| beast = _nearest_village_threat(world, npc) |
| if beast is not None: |
| return directive("attack", target=beast.position, resource_id=None) |
| return directive("patrol") |
|
|
| if goal == "survive_threat_fight": |
| if (next_tick + index) % 3 == 0: |
| return directive("speak", communication_intent="help_request") |
| return directive("strike") |
|
|
| if goal == "survive_threat_flee": |
| if (next_tick + index) % 4 == 0: |
| return directive("speak", communication_intent="help_request") |
| return directive("move", away=True) |
|
|
| if goal == "help_ally": |
| return directive("strike") |
|
|
| if goal == "heal_self": |
| return directive("use", resource_type="herbs") |
|
|
| if goal == "eat_food": |
| return directive("use", resource_type="food") |
|
|
| if goal in ("find_food", "find_herbs"): |
| resource_type = "food" if goal == "find_food" else "herbs" |
| if role == "builder" and goal == "find_food": |
| partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True) |
| if partner is not None: |
| return directive( |
| "communicate", |
| target_npc_id=partner.id, |
| resource_type="food", |
| communication_intent="trade_request", |
| ) |
| return directive("go_home") |
| if goal == "find_food": |
| partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True) |
| if ( |
| partner is not None |
| and npc.inventory_food == 0 |
| and partner.inventory_food > SHARE_FOOD_SURPLUS |
| and npc.relationships.get(partner.id, 0.0) > 0.3 |
| ): |
| return directive( |
| "speak", |
| target_npc_id=partner.id, |
| resource_type="food", |
| communication_intent="trade_request", |
| ) |
| node = _spread_resource(world, npc, index, resource_type=resource_type) |
| if node is not None: |
| if vec_distance(npc.position, node.position) <= GATHER_RADIUS: |
| return directive("use", resource_id=node.id) |
| return directive("move", target=node.position, resource_id=node.id) |
| return directive("move") |
|
|
| if goal == "settle": |
| house = house_containing_npc(world, npc) |
| if ( |
| house is not None |
| and _is_reproduction_candidate(npc) |
| and REPRODUCTION_MAX_HUNGER < npc.hunger <= REPRODUCTION_SNACK_HUNGER |
| and npc.inventory_food > 0 |
| ): |
| return directive("consume") |
| if house is None: |
| return directive("go_home") |
| if (next_tick + index) % 3 == 0: |
| neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS) |
| if neighbor is not None: |
| return directive( |
| "communicate", |
| target_npc_id=neighbor.id, |
| communication_intent="social", |
| ) |
| return directive("go_home") |
|
|
| if goal == "work": |
| if role == "guard": |
| return directive("patrol") |
| if role == "builder": |
| damaged = _nearest_damaged_house(world, npc.position) |
| if damaged is not None and npc.inventory_wood >= HOUSE_REPAIR_WOOD_COST: |
| return directive("build") |
| if npc.build_target_house_id or npc.inventory_wood >= 5: |
| return directive("build") |
| node = _spread_resource(world, npc, index, resource_type="wood") |
| if node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS: |
| return directive("gather", resource_id=node.id) |
| if node is not None: |
| return directive("move_to_resource", target=node.position, resource_id=node.id) |
| return directive("go_home") |
| builder = _nearest_role(world, npc, "builder", max_dist=TRADE_RADIUS) |
| if builder is not None and npc.inventory_wood > 0: |
| return directive( |
| "transfer", |
| target_npc_id=builder.id, |
| resource_type="wood", |
| amount=1, |
| ) |
|
|
| |
| neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS) |
| phase = (next_tick + index) % 4 |
| country = country_for_npc(world, npc) |
| if country is not None and country.treasury is not None and phase == 2: |
| if npc.inventory_coins > 0: |
| return directive( |
| "transfer", |
| target=country.treasury.position, |
| target_entity_id=country.treasury.id, |
| resource_type="coins", |
| amount=1, |
| ) |
| if npc.inventory_wood > 0: |
| return directive( |
| "transfer", |
| target=country.treasury.position, |
| target_entity_id=country.treasury.id, |
| resource_type="wood", |
| amount=1, |
| ) |
| if ( |
| neighbor is not None |
| and npc.inventory_food >= SHARE_FOOD_SURPLUS |
| and neighbor.inventory_food < npc.inventory_food |
| and phase == 0 |
| ): |
| return directive( |
| "transfer", |
| target_npc_id=neighbor.id, |
| resource_type="food", |
| amount=1, |
| ) |
| if neighbor is not None and phase == 1: |
| return directive("speak", target_npc_id=neighbor.id, communication_intent="social") |
| node = _spread_resource(world, npc, index) |
| if node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS: |
| return directive("use", resource_id=node.id) |
| if node is not None and phase != 3: |
| return directive("move", target=node.position, resource_id=node.id) |
| return directive("move") |
|
|
|
|
| def _directive_named_target(world: WorldState, npc: Npc, text: str) -> Npc | None: |
| for other in world.living_npcs(): |
| if other.id == npc.id: |
| continue |
| if other.id.lower() in text or other.name.lower() in text: |
| return other |
| return None |
|
|