| from __future__ import annotations |
|
|
| from world_simulator.domain import CitizenGoal, CitizenMode, Npc, WorldState |
| from world_simulator.simulation.mechanics import distance_between, has_hostile_intent, is_alive |
| from world_simulator.simulation.perception import ( |
| CitizenPerception, |
| build_perception, |
| nearby_threat_npc, |
| recent_attacker_id, |
| ) |
|
|
|
|
| def refresh_citizen_motivation( |
| world: WorldState, |
| npc: Npc, |
| ) -> tuple[CitizenPerception, CitizenGoal | None]: |
| perception = build_perception(world, npc) |
| _update_needs_and_emotions(npc, perception) |
| goal = select_goal(world, npc, perception) |
| npc.current_goal = goal |
| npc.mode = _mode_for_goal(goal) |
| return perception, goal |
|
|
|
|
| def select_goal( |
| world: WorldState, |
| npc: Npc, |
| perception: CitizenPerception, |
| ) -> CitizenGoal | None: |
| if not is_alive(npc): |
| return None |
|
|
| immediate_threat = next( |
| (threat for threat in perception.nearby_threats if threat.get("can_attack_you")), |
| None, |
| ) |
| if immediate_threat is not None: |
| return CitizenGoal( |
| goal_type="survive", |
| target_npc_id=_str_or_none(immediate_threat.get("npc_id")), |
| priority=100, |
| reason=f"{immediate_threat['name']} is close enough to attack", |
| ) |
|
|
| hostile_target = resolve_hostile_directive_target(world, npc) |
| if hostile_target is not None: |
| return CitizenGoal( |
| goal_type="pursue_and_attack_target", |
| target_npc_id=hostile_target.id, |
| priority=98, |
| reason=f"Hostile directive targets {hostile_target.name}", |
| ) |
|
|
| attacker_id = recent_attacker_id(world, npc) |
| if attacker_id is not None: |
| attacker = _npc_by_id(world, attacker_id) |
| if attacker is not None and npc.emotions.anger > npc.emotions.fear: |
| return CitizenGoal( |
| goal_type="retaliate", |
| target_npc_id=attacker.id, |
| priority=92, |
| reason=f"{attacker.name} attacked recently", |
| ) |
| if attacker is not None and _wants_to_call_for_help(npc): |
| return CitizenGoal( |
| goal_type="call_for_help", |
| target_npc_id=attacker.id, |
| priority=90, |
| reason=f"{attacker.name} attacked recently", |
| ) |
| return CitizenGoal( |
| goal_type="survive", |
| target_npc_id=attacker_id, |
| priority=94, |
| reason="This citizen was attacked recently", |
| ) |
|
|
| visible_threat = nearby_threat_npc(world, perception) |
| if visible_threat is not None: |
| return CitizenGoal( |
| goal_type="survive", |
| target_npc_id=visible_threat.id, |
| priority=88, |
| reason=f"{visible_threat.name} is remembered or acting as dangerous nearby", |
| ) |
|
|
| ally_threat = _attacker_from_observed_attack(world, npc, perception) |
| if ally_threat is not None: |
| return CitizenGoal( |
| goal_type="help_or_defend", |
| target_npc_id=ally_threat.id, |
| priority=82, |
| reason=f"{ally_threat.name} attacked someone nearby", |
| ) |
|
|
| if npc.needs.food >= 80: |
| return CitizenGoal( |
| goal_type="find_food", |
| priority=60, |
| reason="Food need is high", |
| ) |
|
|
| return CitizenGoal( |
| goal_type="routine_life", |
| priority=20, |
| reason="No urgent threat or directive is present", |
| ) |
|
|
|
|
| def resolve_hostile_directive_target(world: WorldState, npc: Npc) -> Npc | None: |
| if not has_hostile_intent(npc): |
| return None |
|
|
| directive = (npc.god_directive or "").lower() |
| explicit_target = _explicit_directive_target(world, npc, directive) |
| if explicit_target is not None: |
| return explicit_target |
|
|
| if _directive_allows_any_target(directive): |
| return _nearest_living_target(world, npc) |
|
|
| return _nearest_living_target(world, npc) |
|
|
|
|
| def _update_needs_and_emotions(npc: Npc, perception: CitizenPerception) -> None: |
| if not is_alive(npc): |
| return |
|
|
| if perception.under_threat: |
| npc.needs.safety = max(npc.needs.safety, 95) |
| npc.emotions.fear = max(npc.emotions.fear, 82) |
| npc.emotions.stress = max(npc.emotions.stress, 72) |
| return |
|
|
| if has_hostile_intent(npc): |
| npc.needs.safety = max(npc.needs.safety, 35) |
| npc.emotions.anger = max(npc.emotions.anger, 70) |
| npc.emotions.stress = max(npc.emotions.stress, 45) |
| return |
|
|
| npc.needs.safety = _move_toward(npc.needs.safety, 20, step=5) |
| npc.emotions.fear = _move_toward(npc.emotions.fear, 0, step=5) |
| npc.emotions.anger = _move_toward(npc.emotions.anger, 0, step=3) |
| npc.emotions.stress = _move_toward(npc.emotions.stress, 0, step=5) |
|
|
|
|
| def _mode_for_goal(goal: CitizenGoal | None) -> CitizenMode: |
| if goal is None: |
| return "routine" |
|
|
| match goal.goal_type: |
| case "survive" | "call_for_help": |
| return "threatened" |
| case "pursue_and_attack_target" | "retaliate": |
| return "hostile_pursuit" |
| case "help_or_defend": |
| return "helping" |
| case "respond_to_event" | "investigate": |
| return "investigating" |
| case _: |
| return "routine" |
|
|
|
|
| def _explicit_directive_target(world: WorldState, npc: Npc, directive: str) -> Npc | None: |
| for candidate in world.living_npcs(): |
| if candidate.id == npc.id or not is_alive(candidate): |
| continue |
| if candidate.id.lower() in directive or candidate.name.lower() in directive: |
| return candidate |
| return None |
|
|
|
|
| def _directive_allows_any_target(directive: str) -> bool: |
| return any( |
| keyword in directive |
| for keyword in ( |
| "everyone", |
| "everybody", |
| "all", |
| "anyone", |
| "else", |
| "visible", |
| "citizen", |
| "npc", |
| ) |
| ) |
|
|
|
|
| def _nearest_living_target(world: WorldState, npc: Npc) -> Npc | None: |
| candidates = [ |
| (distance_between(npc, candidate), candidate.id, candidate) |
| for candidate in world.living_npcs() |
| if candidate.id != npc.id and is_alive(candidate) |
| ] |
| if not candidates: |
| return None |
| return min(candidates, key=lambda item: (item[0], item[1]))[2] |
|
|
|
|
| def _attacker_from_observed_attack( |
| world: WorldState, |
| npc: Npc, |
| perception: CitizenPerception, |
| ) -> Npc | None: |
| for episode in reversed(npc.structured_memory.episodes): |
| if ( |
| episode.kind == "attack" |
| and episode.subject_kind == "npc" |
| and episode.perspective == "witness" |
| and episode.actor_id != npc.id |
| ): |
| attacker = _npc_by_id(world, episode.actor_id) |
| if attacker is not None and is_alive(attacker): |
| return attacker |
|
|
| for text in reversed(perception.last_hostile_actions): |
| lowered = text.lower() |
| if "attacked you" in lowered: |
| continue |
| if "attacked" not in lowered: |
| continue |
| for candidate in world.living_npcs(): |
| if candidate.id == npc.id: |
| continue |
| if candidate.name.lower() in lowered or candidate.id.lower() in lowered: |
| return candidate |
| return None |
|
|
|
|
| def _npc_by_id(world: WorldState, npc_id: str) -> Npc | None: |
| return next((npc for npc in world.living_npcs() if npc.id == npc_id), None) |
|
|
|
|
| def _wants_to_call_for_help(npc: Npc) -> bool: |
| personality = npc.personality.lower() |
| return "social" in personality or "cautious" in personality or npc.role != "guard" |
|
|
|
|
| def _str_or_none(raw: object) -> str | None: |
| return raw if isinstance(raw, str) else None |
|
|
|
|
| def _move_toward(value: int, target: int, *, step: int) -> int: |
| if value < target: |
| return min(value + step, target) |
| if value > target: |
| return max(value - step, target) |
| return value |
|
|