Spaces:
Runtime error
Runtime error
| """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 / health economy. | |
| 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 # health < this (with herbs) -> heal_self | |
| FIND_HERBS_HEALTH = 50 # health < this (without herbs) -> find_herbs | |
| EAT_GOAL_HUNGER = 60.0 | |
| REPRODUCTION_SNACK_HUNGER = 45.0 | |
| # A would-be parent only needs to be reasonably fed (not literally stuffed) to | |
| # have a child. Set well below REPRODUCTION_SNACK_HUNGER so a single meal closes | |
| # the gap, otherwise families almost never form within a demo run. | |
| REPRODUCTION_MAX_HUNGER = 25.0 | |
| # Awareness radii (world units). | |
| 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 # how far ordinary speech/shouts are heard by others | |
| HOUSE_RADIUS = 2.5 * TILE | |
| HOUSE_BUILD_RADIUS = 2 * TILE | |
| HOUSE_MIN_DISTANCE = 4 * TILE | |
| # Repairing a standing-but-damaged home: cheap, fast, and the builder's top job | |
| # when beasts are chewing on the base. Distinct from rebuilding a destroyed ruin. | |
| HOUSE_REPAIR_PER_ACTION = 12.0 | |
| HOUSE_REPAIR_WOOD_COST = 1 | |
| # Fear dynamics. | |
| 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 | |
| # Movement / combat (world units). Beasts are intentionally hard to solo and | |
| # damaging without instantly ending the village story. | |
| NPC_MOVE_STEP = 1 * TILE | |
| FLEE_STEP = 2 * TILE | |
| NPC_MELEE_REACH = ATTACK_RADIUS # one tile, shared with beasts (see mechanics.ATTACK_RADIUS) | |
| BASE_NPC_DAMAGE = 10 | |
| WEAPON_DAMAGE_BONUS = 0 | |
| # Coin economy: productive work pays a wage the citizen banks in the treasury, so | |
| # the nation grows rich through labour (gathering, building, hunting) rather than | |
| # only through raids. Wages land in inventory_coins; a transfer to the treasury | |
| # turns them into national wealth (treasury_deposit events). | |
| 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 | |
| # Deterministic world-system spawning. Positions and ids are random-looking but | |
| # stable for a world seed/tick pair. | |
| RESOURCE_SPAWN_INTERVAL = 7 | |
| MAX_RESOURCE_NODES = 18 | |
| BEAST_SPAWN_INTERVAL = 60 | |
| MAX_BEASTS = 3 | |
| # Resource regrowth: every node regrows +1 toward its cap on this cadence, so the | |
| # settlement is sustainable instead of starving once nodes are first depleted. | |
| RESOURCE_REGEN_INTERVALS = {"food": 8, "herbs": 12, "wood": 10, "weapon": 40} | |
| RESOURCE_REGEN_AMOUNT = 1 | |
| # How many of the nearest non-empty nodes foragers spread across (anti-clumping). | |
| SPREAD_NODE_CHOICES = 3 | |
| # Minimum surplus food before an NPC is willing to share with a needier neighbor. | |
| 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 -> allowed survival effect verbs (engine-side: the deterministic role | |
| # planner chooses from these sets, and role whitelists filter them per NPC). | |
| 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 -> ranked LLM-facing suggestions, best first. These are public tools; | |
| # internal survival verbs such as gather/build/go_home map through use/move. | |
| 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"} | |
| ) | |
| # Internal effect verbs the resolver knows how to execute. Primitives are | |
| # translated to one of these in validate_survival_action; the verbs never leave | |
| # this module. | |
| 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", | |
| } | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Memory, relationships, and perception | |
| # --------------------------------------------------------------------------- # | |
| 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" | |
| # --------------------------------------------------------------------------- # | |
| # Spatial queries | |
| # --------------------------------------------------------------------------- # | |
| 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.""" | |
| # Local import: chaos.py reuses survival helpers at module level. | |
| 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, # type: ignore[arg-type] | |
| ) | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Deterministic environment tick (no LLM) | |
| # --------------------------------------------------------------------------- # | |
| 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) | |
| # Keep the live goal current for the snapshot, even when planning ran on a | |
| # deep-copied world (the HTTP server path). | |
| 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) | |
| # A child belongs to its parents' nation and is driven by the same model | |
| # (connector). Without inheriting these the newborn is stateless: no country, | |
| # no treasury to fund, and wrongly routed to the default model. | |
| 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: | |
| # Everyone is a free citizen with no fixed job, so children are too. | |
| 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 | |
| # --------------------------------------------------------------------------- # | |
| # Goal selection + action mask | |
| # --------------------------------------------------------------------------- # | |
| 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 | |
| # NOTE: a nearby beast no longer forces a flee/engage goal. The beast is shown | |
| # in the briefing's THREATS section (with attack/move options) and the LLM | |
| # decides for itself whether to fight, flee, or call for help. | |
| 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" # explicit NPC target; effect approaches if needed | |
| beast = _beast_from_directive(world, npc, directive) | |
| if beast is None: | |
| return "defend" | |
| return "attack" # apply_action_effects approaches when out of melee reach | |
| 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": | |
| # A transfer aimed at a treasury always resolves through the treasury | |
| # handler, which honors `take` (steal from a rival) vs deposit. Routing | |
| # take->NPC-steal here would bypass treasury raids entirely. | |
| 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" | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Effect resolver (engine owns all state changes) | |
| # --------------------------------------------------------------------------- # | |
| 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": | |
| # Free wander in a deterministic-but-varied direction (anti-clumping). | |
| 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) | |
| # Share food from whoever has a surplus with the neediest nearby neighbour, | |
| # so food flows from haves to have-nots instead of people starving alone. | |
| 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": | |
| # Whom to rob is the model's call. Honor an explicit target (even a | |
| # countryman, if the model truly decides so); only fall back to the | |
| # nearest food-holder when no target was named. | |
| 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 | |
| # Conversations read better when the listener turns toward the speaker too. | |
| 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: | |
| # Bystanders within earshot overhear directed speech too. | |
| _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}" | |
| # No explicit listener: a shout heard by everyone within earshot. | |
| 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 | |
| # Priority 0: patch up a standing-but-damaged home before it collapses. | |
| # Defending the base outranks finishing a new build or hauling more wood. | |
| 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, | |
| ) | |
| # Every living member of the robbed country learns who took from their | |
| # treasury, so they can confront the thief (grievance -> demand -> fight). | |
| 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: | |
| # Convention matches the briefing compass: +X=WEST, -X=EAST, +Z=NORTH, -Z=SOUTH. | |
| 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) | |
| # --------------------------------------------------------------------------- # | |
| # Deterministic survival planner + resolver | |
| # --------------------------------------------------------------------------- # | |
| 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 | |
| # Side-channel speech: words said ALONGSIDE a non-speak action. The speak | |
| # action itself already emits its message, so skip it to avoid doubling. | |
| if directive.speech and directive.action != "speak": | |
| _emit_side_speech(world, npc, directive.speech) | |
| # Surface the model's chain-of-thought on the live NPC for the UI. Keep | |
| # the last real thought when a turn produced none (e.g. forced retry). | |
| 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) | |
| # The NPC owns its own long-term memory: when the model rewrote its memory | |
| # note this turn, that REPLACES the engine's mechanical tick dump. Set it | |
| # LAST, after every remember() above, so compaction can't clobber it. | |
| # Strip any "Tick N:" labels - the note is prose, never a tick log. | |
| 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, # type: ignore[arg-type] | |
| 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") # nothing to gather right now -> wander, don't freeze | |
| 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") # _apply_build repairs the damaged home first | |
| 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, | |
| ) | |
| # routine_life/work fallback: socialise, share, forage, or wander -- and spread out. | |
| 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") # wander to disperse | |
| 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 | |