Spaces:
Sleeping
Sleeping
| """ | |
| Protagonist World Simulator — single-file Hugging Face Gradio Space | |
| Usage: | |
| 1. Create a Hugging Face Space and choose the Gradio SDK. | |
| 2. Replace the generated app.py with this file. | |
| 3. Commit. No requirements.txt is needed; the app uses Python's standard | |
| library plus the Gradio package already supplied by Gradio Spaces. | |
| The simulation runs on the Space's CPU. It is a single shared world for all | |
| connected visitors. Export a save before changing hardware or restarting. | |
| """ | |
| from __future__ import annotations | |
| import gzip | |
| import html | |
| import json | |
| import math | |
| import os | |
| import random | |
| import secrets | |
| import tempfile | |
| import threading | |
| import time | |
| from collections import Counter, defaultdict, deque | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, Iterable | |
| import gradio as gr | |
| # ----------------------------------------------------------------------------- | |
| # Content tables | |
| # ----------------------------------------------------------------------------- | |
| FIRST_NAMES = [ | |
| "Ariadne", "Bram", "Cael", "Cassian", "Delia", "Dorian", "Edrin", | |
| "Elowen", "Fael", "Fen", "Garrick", "Ilyra", "Isolde", "Jarek", | |
| "Joren", "Kestrel", "Liora", "Neris", "Nyx", "Orren", "Petra", | |
| "Rhea", "Riven", "Theron", "Ulric", "Vale", "Veyra", "Wren", | |
| "Xavian", "Ysolde", "Zev", "Maelis", "Sorren", "Tamsin", "Voren", | |
| "Seraphine", "Mara", "Lucan", "Ione", "Caldris", "Eira", "Tavian", | |
| ] | |
| LAST_NAMES = [ | |
| "Ashfall", "Briar", "Coldwater", "Crowe", "Dawnmarch", "Farrow", | |
| "Frost", "Gloam", "Hallow", "Harrow", "Ironwood", "Ivory", "Jade", | |
| "Kingsley", "Lark", "Nightveil", "North", "Oakheart", "Pyre", | |
| "Redwater", "Silver", "Tarn", "Thorne", "Umber", "Valeborn", "Voss", | |
| "Yarrow", "Zephyr", "Blackglass", "Starling", "Mourn", "Vale", | |
| ] | |
| ARCHETYPES = [ | |
| "monster hunter", "occult scholar", "mercenary captain", "courtier", | |
| "relic thief", "wandering healer", "exiled heir", "smuggler", | |
| "battle-mage", "investigator", "prophet", "merchant adventurer", | |
| "renegade priest", "duelist", "cartographer", "revolutionary", | |
| "grave robber", "diplomat", "beast tamer", "witch-knight", | |
| ] | |
| TRAIT_NAMES = [ | |
| "ambition", "courage", "cunning", "empathy", "discipline", "curiosity", | |
| "cruelty", "loyalty", "will", "sociability", "greed", "spirituality", | |
| ] | |
| GOALS: dict[str, list[str]] = { | |
| "found a sovereign city": [ | |
| "identify defensible land", "secure a legal or forged claim", | |
| "recruit settlers and specialists", "clear local threats", | |
| "construct the first district", "declare the city and survive retaliation", | |
| ], | |
| "destroy a hated faction": [ | |
| "identify the faction's vulnerable officials", "build an opposing coalition", | |
| "seize money and intelligence", "eliminate a key protector", | |
| "fracture the faction internally", "deliver the final political or military blow", | |
| ], | |
| "become the greatest monster slayer": [ | |
| "study dangerous creatures", "obtain specialized equipment", | |
| "join or create a hunting company", "kill a notorious monster", | |
| "survive a legendary hunt", "claim a title recognized across the world", | |
| ], | |
| "escape mortality": [ | |
| "collect forbidden accounts of immortality", "identify an ancient patron", | |
| "obtain a vessel or relic", "perform a transformative rite", | |
| "survive the price demanded", "stabilize a form beyond ordinary death", | |
| ], | |
| "assemble an irresistible retinue": [ | |
| "earn enough fame to attract followers", "recruit a useful specialist", | |
| "bind the group with wealth or devotion", "defeat a rival leader", | |
| "establish a permanent headquarters", "become a power no ruler can ignore", | |
| ], | |
| "uncover the world's first secret": [ | |
| "collect contradictory creation myths", "locate a prehuman ruin", | |
| "recover an impossible artifact", "decode a forbidden language", | |
| "contact a witness older than history", "reveal or monopolize the truth", | |
| ], | |
| "create a dynasty": [ | |
| "accumulate wealth and status", "secure a politically useful bond", | |
| "obtain an estate or stronghold", "eliminate succession rivals", | |
| "establish laws of inheritance", "make the family name outlive its founder", | |
| ], | |
| "become ruler of a major faction": [ | |
| "join a suitable faction", "earn standing through useful work", | |
| "build a dependable internal bloc", "discredit a superior", | |
| "win a succession struggle", "reshape the faction around a personal doctrine", | |
| ], | |
| "collect seven world-changing artifacts": [ | |
| "research the first artifact", "recover an artifact from a ruin", | |
| "learn who owns another", "win or steal a contested relic", | |
| "survive an artifact's corruption", "assemble a collection that changes history", | |
| ], | |
| "liberate every bound servant encountered": [ | |
| "learn how magical bonds function", "identify a vulnerable controller", | |
| "free a first captive", "build a refuge network", "break a powerful binding order", | |
| "make bondage politically and magically impossible in one region", | |
| ], | |
| "become a living god": [ | |
| "acquire occult literacy", "make contact with an ancient power", | |
| "accept or subvert a supernatural mark", "gather worshippers", | |
| "survive partial incarnation", "replace, consume, or become a god", | |
| ], | |
| "make the world remember a perfect crime": [ | |
| "study law and authority", "construct a false identity", | |
| "commit an audacious theft", "turn investigators against one another", | |
| "escape a continental manhunt", "reveal authorship only when untouchable", | |
| ], | |
| } | |
| FACTION_TEMPLATES = [ | |
| ("The Dawn Compact", "defend settlements and regulate dangerous relics"), | |
| ("The Veiled Synod", "acquire forbidden knowledge and contact the old powers"), | |
| ("The Gilded Exchange", "control trade, debt, information, and useful corruption"), | |
| ("The Free Blades", "turn adventurers into an independent military power"), | |
| ("The Verdant Covenant", "protect wild regions and cultivate living magic"), | |
| ("The Ashen Crown", "replace weak rulers through force and fear"), | |
| ("The Lantern Office", "investigate supernatural threats and enforce hidden law"), | |
| ("The Black Archive", "preserve dangerous truths by monopolizing them"), | |
| ("The Salt Republic", "expand free cities and break hereditary authority"), | |
| ("The Choir of Brass", "perfect civilization through ritual machinery"), | |
| ("The Pilgrim Host", "prepare the world for a prophesied migration"), | |
| ("The Ivory Menagerie", "capture, breed, bargain with, and weaponize monsters"), | |
| ] | |
| BIOMES = { | |
| "plains": "#4f673e", "forest": "#294c38", "hills": "#59604a", | |
| "mountains": "#555760", "swamp": "#354f4a", "ash": "#55484c", | |
| "desert": "#806d48", "tundra": "#677681", "water": "#263d59", | |
| } | |
| PLACE_PREFIX = [ | |
| "Gold", "Iron", "Silver", "Moon", "Dawn", "Storm", "Bright", "Grey", | |
| "Black", "White", "Red", "Kings", "Old", "Low", "Hollow", "Sun", | |
| "Glass", "Star", "Thorn", "Mourn", "Saint", "Wolf", "Cinder", "Dream", | |
| ] | |
| PLACE_SUFFIX = [ | |
| "hold", "haven", "rest", "watch", "field", "bridge", "ford", "keep", | |
| "reach", "port", "market", "cairn", "spire", "fall", "hollow", "gate", | |
| "mouth", "ward", "cross", "depths", | |
| ] | |
| MONSTER_BASES = [ | |
| "Gravejaw", "Thousand-Mouth Horror", "Dream Leech", "Cinder Seraph", | |
| "Mirror Basilisk", "Hollow Colossus", "Name-Eater", "Velvet Devourer", | |
| "Bell-Worm", "Crowned Manticore", "Ash Leviathan", "Bone Orchard", | |
| "Glass Widow", "Moonless Stag", "Choir Dragon", "Memory Wolf", | |
| "Saint-Eating Giant", "Pale Engine", "Red Oracle", "Road Kraken", | |
| ] | |
| MONSTER_EPITHETS = [ | |
| "the Red", "the Crowned", "of Nine Graves", "Beneath the Glass", | |
| "Who Remembers Tomorrow", "the Unburied", "of the Empty Choir", | |
| "the Patient", "the Lawless", "of Last Winter", "the Starved King", | |
| ] | |
| PATRONS = [ | |
| ("Asterion Unremembered", "memory, absence, and histories that never occurred"), | |
| ("The Choir Behind Sleep", "dreams, obedience, and shared thought"), | |
| ("Veyruun Beneath the Glass", "mirrors, appetite, and beautiful imprisonment"), | |
| ("The King in the Last Hour", "endings, bargains, and borrowed futures"), | |
| ("The Pale Cartographer", "roads, distance, and places that should not exist"), | |
| ("Mother of the Velvet Deep", "dependency, metamorphosis, and merciful corruption"), | |
| ] | |
| WILD_ANOMALIES = [ | |
| "a road becomes sentient and begins moving favoured towns closer together", | |
| "a dead hero's memories divide themselves among seven living strangers", | |
| "a fabricated saint receives enough belief to become real", | |
| "two copies of the same city appear and both accuse the other of being counterfeit", | |
| "an artifact begins sabotaging every relationship around its beloved wielder", | |
| "a monster-hunting company is exposed for secretly breeding stronger monsters", | |
| "a region's inhabitants begin sharing one dream and developing a distributed mind", | |
| "a fog devours legal names while leaving bodies untouched", | |
| "an ancient creature awakens with a refugee settlement unknowingly built on its back", | |
| "a god offers formal surrender to a mortal and starts redefining the meaning of loyalty", | |
| "the moon briefly displays a second face recognizable as a living NPC", | |
| "every spoken promise within one city becomes physically binding", | |
| ] | |
| RANK_NAMES = ["Initiate", "Agent", "Lieutenant", "Warden", "Master", "Oracle", "Director", "Sovereign"] | |
| # ----------------------------------------------------------------------------- | |
| # Utilities | |
| # ----------------------------------------------------------------------------- | |
| def clamp(value: float, low: float, high: float) -> float: | |
| return low if value < low else high if value > high else value | |
| def seeded_hash(seed: int, x: int, y: int, salt: int = 0) -> float: | |
| """Stable integer hash mapped to [0, 1).""" | |
| n = (seed ^ (x * 0x45D9F3B) ^ (y * 0x27D4EB2D) ^ (salt * 0x165667B1)) & 0xFFFFFFFF | |
| n = ((n ^ (n >> 16)) * 0x45D9F3B) & 0xFFFFFFFF | |
| n = ((n ^ (n >> 16)) * 0x45D9F3B) & 0xFFFFFFFF | |
| n ^= n >> 16 | |
| return (n & 0xFFFFFFFF) / 2**32 | |
| def weighted_pick(rng: random.Random, items: list[tuple[Any, float]]) -> Any: | |
| total = sum(max(0.0, weight) for _, weight in items) | |
| if total <= 0: | |
| return items[-1][0] | |
| roll = rng.random() * total | |
| upto = 0.0 | |
| for item, weight in items: | |
| upto += max(0.0, weight) | |
| if roll <= upto: | |
| return item | |
| return items[-1][0] | |
| def json_tuple(value: Any) -> Any: | |
| if isinstance(value, tuple): | |
| return [json_tuple(v) for v in value] | |
| if isinstance(value, list): | |
| return [json_tuple(v) for v in value] | |
| return value | |
| def tuple_tree(value: Any) -> Any: | |
| if isinstance(value, list): | |
| return tuple(tuple_tree(v) for v in value) | |
| return value | |
| def safe(text: Any) -> str: | |
| return html.escape(str(text), quote=True) | |
| def fmt_int(value: int | float) -> str: | |
| return f"{int(value):,}" | |
| class Candidate: | |
| action: str | |
| score: float | |
| reason: str | |
| payload: dict[str, Any] | |
| # ----------------------------------------------------------------------------- | |
| # World engine | |
| # ----------------------------------------------------------------------------- | |
| class World: | |
| def __init__( | |
| self, | |
| seed: int | None = None, | |
| npc_count: int = 540, | |
| width: int = 84, | |
| height: int = 56, | |
| generate: bool = True, | |
| ) -> None: | |
| self.seed = int(seed if seed is not None else secrets.randbits(31)) | |
| self.rng = random.Random(self.seed) | |
| self.width = int(width) | |
| self.height = int(height) | |
| self.day = 0 | |
| self.paused = True | |
| self.speed = 4.0 | |
| self.created_at = time.time() | |
| self.npcs: dict[int, dict[str, Any]] = {} | |
| self.monsters: dict[int, dict[str, Any]] = {} | |
| self.places: dict[int, dict[str, Any]] = {} | |
| self.factions: dict[int, dict[str, Any]] = {} | |
| self.logs: deque[dict[str, Any]] = deque(maxlen=20_000) | |
| self.major_logs: deque[dict[str, Any]] = deque(maxlen=4_000) | |
| self.archived_counts: Counter[str] = Counter() | |
| self.response_teams: dict[int, dict[str, Any]] = {} | |
| self.operations: list[dict[str, Any]] = [] | |
| self.anomalies: list[dict[str, Any]] = [] | |
| self.artifacts: dict[int, dict[str, Any]] = {} | |
| self.next_npc_id = 1 | |
| self.next_monster_id = 1 | |
| self.next_place_id = 1 | |
| self.next_artifact_id = 1 | |
| self.terrain: list[list[str]] = [] | |
| self.npc_tiles: defaultdict[tuple[int, int], set[int]] = defaultdict(set) | |
| self.monster_tiles: defaultdict[tuple[int, int], set[int]] = defaultdict(set) | |
| self.place_tiles: defaultdict[tuple[int, int], list[int]] = defaultdict(list) | |
| self._terrain_svg = "" | |
| if generate: | |
| self._generate_terrain() | |
| self._generate_factions() | |
| self._generate_places(max(120, min(420, npc_count // 3))) | |
| self._generate_npcs(npc_count) | |
| self._generate_monsters(max(45, npc_count // 5)) | |
| self._seed_relationships() | |
| self._build_terrain_svg() | |
| self.emit( | |
| f"The world opens with {len(self.npcs)} protagonist-class lives already in motion.", | |
| "world", 5, thought="Every person begins with a private ambition, incomplete knowledge, and a plan that can be revised by experience.", | |
| ) | |
| # -- generation --------------------------------------------------------- | |
| def _generate_terrain(self) -> None: | |
| self.terrain = [] | |
| for y in range(self.height): | |
| row: list[str] = [] | |
| latitude = abs((y / max(1, self.height - 1)) - 0.5) * 2 | |
| for x in range(self.width): | |
| continental = ( | |
| math.sin(x * 0.15 + self.seed % 17) * 0.23 | |
| + math.cos(y * 0.19 + self.seed % 11) * 0.20 | |
| + math.sin((x + y) * 0.07) * 0.16 | |
| + seeded_hash(self.seed, x // 3, y // 3, 1) * 0.55 | |
| ) | |
| moisture = seeded_hash(self.seed, x // 4, y // 4, 2) + math.sin(y * 0.12) * 0.2 | |
| rough = seeded_hash(self.seed, x // 3, y // 3, 3) | |
| if continental < 0.18: | |
| biome = "water" | |
| elif latitude > 0.83 and rough > 0.32: | |
| biome = "tundra" | |
| elif rough > 0.88: | |
| biome = "mountains" | |
| elif rough > 0.72: | |
| biome = "hills" | |
| elif moisture > 0.84: | |
| biome = "swamp" | |
| elif moisture > 0.60: | |
| biome = "forest" | |
| elif moisture < 0.18: | |
| biome = "desert" | |
| elif seeded_hash(self.seed, x, y, 9) > 0.965: | |
| biome = "ash" | |
| else: | |
| biome = "plains" | |
| row.append(biome) | |
| self.terrain.append(row) | |
| def _generate_factions(self) -> None: | |
| for idx, (name, agenda) in enumerate(FACTION_TEMPLATES): | |
| self.factions[idx] = { | |
| "id": idx, | |
| "name": name, | |
| "agenda": agenda, | |
| "power": self.rng.randint(45, 90), | |
| "treasury": self.rng.randint(500, 1800), | |
| "members": set(), | |
| "leader": None, | |
| "relations": {}, | |
| "victories": 0, | |
| "defeats": 0, | |
| "doctrine": self.rng.choice(["pragmatic", "zealous", "secretive", "mercantile", "militant", "idealistic"]), | |
| } | |
| for fid, faction in self.factions.items(): | |
| for other_id in self.factions: | |
| if other_id != fid: | |
| faction["relations"][other_id] = self.rng.randint(-35, 35) | |
| def _random_land(self) -> tuple[int, int]: | |
| for _ in range(500): | |
| x = self.rng.randrange(self.width) | |
| y = self.rng.randrange(self.height) | |
| if self.terrain[y][x] != "water": | |
| return x, y | |
| return self.width // 2, self.height // 2 | |
| def _place_name(self) -> str: | |
| return self.rng.choice(PLACE_PREFIX) + self.rng.choice(PLACE_SUFFIX) | |
| def _generate_places(self, count: int) -> None: | |
| used: set[tuple[int, int]] = set() | |
| types = [ | |
| ("city", 0.11), ("town", 0.18), ("fort", 0.10), ("market", 0.08), | |
| ("ruin", 0.22), ("shrine", 0.10), ("academy", 0.05), | |
| ("estate", 0.06), ("lair", 0.10), | |
| ] | |
| for _ in range(count): | |
| for _attempt in range(100): | |
| x, y = self._random_land() | |
| if (x, y) not in used: | |
| used.add((x, y)) | |
| break | |
| ptype = weighted_pick(self.rng, types) | |
| if ptype == "shrine": | |
| patron = self.rng.choice(PATRONS)[0] | |
| name = f"Shrine of {patron}" | |
| elif ptype == "ruin": | |
| name = f"Ruins of {self._place_name()}" | |
| elif ptype == "lair": | |
| name = f"The {self.rng.choice(['Sunken', 'Whispering', 'Red', 'Hollow', 'Glass'])} Lair" | |
| else: | |
| name = self._place_name() | |
| pop = { | |
| "city": self.rng.randint(5_000, 28_000), | |
| "town": self.rng.randint(800, 5_500), | |
| "fort": self.rng.randint(250, 1_500), | |
| "market": self.rng.randint(300, 2_500), | |
| "academy": self.rng.randint(120, 900), | |
| "estate": self.rng.randint(40, 400), | |
| }.get(ptype, 0) | |
| pid = self.next_place_id | |
| self.next_place_id += 1 | |
| place = { | |
| "id": pid, | |
| "name": name, | |
| "type": ptype, | |
| "x": x, "y": y, | |
| "population": pop, | |
| "security": self.rng.randint(25, 85) if pop else self.rng.randint(0, 30), | |
| "prosperity": self.rng.randint(20, 90) if pop else 0, | |
| "damage": 0, | |
| "state": "stable" if pop else "unclaimed", | |
| "faction": self.rng.randrange(len(self.factions)) if pop and self.rng.random() < 0.7 else None, | |
| "treasure": self.rng.randint(180, 900) if ptype == "ruin" else 0, | |
| "depth": self.rng.randint(3, 12) if ptype == "ruin" else 0, | |
| "exhausted": False, | |
| "history": [], | |
| "price_index": round(self.rng.uniform(0.75, 1.35), 2), | |
| } | |
| self.places[pid] = place | |
| self.place_tiles[(x, y)].append(pid) | |
| def _make_name(self) -> str: | |
| return f"{self.rng.choice(FIRST_NAMES)} {self.rng.choice(LAST_NAMES)}" | |
| def _generate_npcs(self, count: int) -> None: | |
| settlement_ids = [pid for pid, p in self.places.items() if p["population"] > 0] | |
| for _ in range(count): | |
| nid = self.next_npc_id | |
| self.next_npc_id += 1 | |
| if settlement_ids and self.rng.random() < 0.78: | |
| home = self.places[self.rng.choice(settlement_ids)] | |
| x, y = home["x"], home["y"] | |
| home_id = home["id"] | |
| else: | |
| x, y = self._random_land() | |
| home_id = None | |
| traits = {name: self.rng.randint(15, 95) for name in TRAIT_NAMES} | |
| goal = self.rng.choice(list(GOALS)) | |
| faction_id = self.rng.randrange(len(self.factions)) if self.rng.random() < 0.58 else None | |
| npc = { | |
| "id": nid, | |
| "name": self._make_name(), | |
| "archetype": self.rng.choice(ARCHETYPES), | |
| "age": self.rng.randint(18, 61), | |
| "x": x, "y": y, | |
| "home": home_id, | |
| "alive": True, | |
| "level": 1, | |
| "xp": 0, | |
| "hp": self.rng.randint(75, 110), | |
| "max_hp": self.rng.randint(95, 125), | |
| "wealth": self.rng.randint(10, 110), | |
| "fame": 0, | |
| "infamy": 0, | |
| "suspicion": 0, | |
| "corruption": self.rng.randint(0, 8), | |
| "lore": self.rng.randint(0, 8), | |
| "faction": faction_id, | |
| "standing": self.rng.randint(0, 15) if faction_id is not None else 0, | |
| "rank": 0, | |
| "traits": traits, | |
| "goal": goal, | |
| "plan": list(GOALS[goal]), | |
| "plan_index": 0, | |
| "plan_progress": 0, | |
| "objective": "gather information", | |
| "target_place": None, | |
| "target_npc": None, | |
| "target_monster": None, | |
| "emotions": { | |
| "hope": self.rng.randint(30, 75), | |
| "fear": self.rng.randint(5, 40), | |
| "anger": self.rng.randint(0, 30), | |
| "loneliness": self.rng.randint(10, 55), | |
| "obsession": self.rng.randint(0, 30), | |
| }, | |
| "relationships": {}, | |
| "memories": deque(maxlen=180), | |
| "followers": set(), | |
| "bound_to": None, | |
| "imprisoned_until": 0, | |
| "artifacts": [], | |
| "patron": None, | |
| "occult_stage": 0, | |
| "vessel_compatibility": 0, | |
| "titles": [], | |
| "kills": 0, | |
| "monster_kills": 0, | |
| "rumours": [], | |
| "last_action": "arrived in the world", | |
| "days_idle": 0, | |
| } | |
| npc["max_hp"] = max(npc["max_hp"], npc["hp"]) | |
| self.npcs[nid] = npc | |
| self.npc_tiles[(x, y)].add(nid) | |
| if faction_id is not None: | |
| self.factions[faction_id]["members"].add(nid) | |
| for fid, faction in self.factions.items(): | |
| members = [self.npcs[n] for n in faction["members"] if self.npcs[n]["alive"]] | |
| if members: | |
| leader = max(members, key=lambda n: n["standing"] + n["traits"]["ambition"] / 4) | |
| faction["leader"] = leader["id"] | |
| leader["rank"] = 3 | |
| def _generate_monsters(self, count: int) -> None: | |
| lairs = [p for p in self.places.values() if p["type"] == "lair"] | |
| for _ in range(count): | |
| mid = self.next_monster_id | |
| self.next_monster_id += 1 | |
| if lairs and self.rng.random() < 0.65: | |
| lair = self.rng.choice(lairs) | |
| x, y = lair["x"], lair["y"] | |
| else: | |
| x, y = self._random_land() | |
| level = max(1, int(self.rng.paretovariate(2.5) * 2)) | |
| name = f"{self.rng.choice(MONSTER_BASES)} {self.rng.choice(MONSTER_EPITHETS)}" | |
| monster = { | |
| "id": mid, | |
| "name": name, | |
| "x": x, "y": y, | |
| "level": level, | |
| "hp": 45 + level * 18, | |
| "max_hp": 45 + level * 18, | |
| "notoriety": self.rng.randint(0, 18), | |
| "alive": True, | |
| "kills": 0, | |
| "adaptations": [], | |
| "temperament": self.rng.choice(["territorial", "predatory", "migratory", "cunning", "cultivated"]), | |
| "last_target": None, | |
| } | |
| self.monsters[mid] = monster | |
| self.monster_tiles[(x, y)].add(mid) | |
| def _seed_relationships(self) -> None: | |
| for tile, ids in list(self.npc_tiles.items()): | |
| local = list(ids) | |
| self.rng.shuffle(local) | |
| for nid in local[: min(12, len(local))]: | |
| others = [x for x in local if x != nid] | |
| for oid in self.rng.sample(others, k=min(len(others), self.rng.randint(0, 3))): | |
| rel = self.relationship(nid, oid) | |
| rel["trust"] = self.rng.randint(-10, 35) | |
| rel["attraction"] = self.rng.randint(0, 30) | |
| rel["fear"] = self.rng.randint(0, 15) | |
| rel["history"].append("They knew one another before the chronicle began.") | |
| # -- indexing and lookup ------------------------------------------------ | |
| def _build_terrain_svg(self) -> None: | |
| cell = 10 | |
| chunks: list[str] = [] | |
| for y, row in enumerate(self.terrain): | |
| run_start = 0 | |
| current = row[0] | |
| for x in range(1, self.width + 1): | |
| changed = x == self.width or row[x] != current | |
| if changed: | |
| width = (x - run_start) * cell | |
| chunks.append( | |
| f'<rect x="{run_start * cell}" y="{y * cell}" width="{width}" height="{cell}" fill="{BIOMES[current]}"/>' | |
| ) | |
| if x < self.width: | |
| run_start = x | |
| current = row[x] | |
| self._terrain_svg = "".join(chunks) | |
| def move_npc(self, npc: dict[str, Any], x: int, y: int) -> None: | |
| old = (npc["x"], npc["y"]) | |
| self.npc_tiles[old].discard(npc["id"]) | |
| npc["x"] = int(clamp(x, 0, self.width - 1)) | |
| npc["y"] = int(clamp(y, 0, self.height - 1)) | |
| self.npc_tiles[(npc["x"], npc["y"])].add(npc["id"]) | |
| def move_monster(self, monster: dict[str, Any], x: int, y: int) -> None: | |
| old = (monster["x"], monster["y"]) | |
| self.monster_tiles[old].discard(monster["id"]) | |
| monster["x"] = int(clamp(x, 0, self.width - 1)) | |
| monster["y"] = int(clamp(y, 0, self.height - 1)) | |
| self.monster_tiles[(monster["x"], monster["y"])].add(monster["id"]) | |
| def place_at(self, x: int, y: int, kinds: Iterable[str] | None = None) -> dict[str, Any] | None: | |
| ids = self.place_tiles.get((x, y), []) | |
| if kinds is None: | |
| return self.places[ids[0]] if ids else None | |
| allowed = set(kinds) | |
| for pid in ids: | |
| if self.places[pid]["type"] in allowed: | |
| return self.places[pid] | |
| return None | |
| def nearest_place(self, x: int, y: int, kinds: Iterable[str] | None = None) -> dict[str, Any] | None: | |
| allowed = set(kinds) if kinds else None | |
| candidates = [p for p in self.places.values() if allowed is None or p["type"] in allowed] | |
| if not candidates: | |
| return None | |
| return min(candidates, key=lambda p: abs(p["x"] - x) + abs(p["y"] - y)) | |
| def local_npcs(self, npc: dict[str, Any], radius: int = 0) -> list[dict[str, Any]]: | |
| result: list[dict[str, Any]] = [] | |
| for yy in range(max(0, npc["y"] - radius), min(self.height, npc["y"] + radius + 1)): | |
| for xx in range(max(0, npc["x"] - radius), min(self.width, npc["x"] + radius + 1)): | |
| for nid in self.npc_tiles.get((xx, yy), ()): | |
| if nid != npc["id"] and self.npcs[nid]["alive"]: | |
| result.append(self.npcs[nid]) | |
| return result | |
| def local_monsters(self, x: int, y: int, radius: int = 0) -> list[dict[str, Any]]: | |
| result: list[dict[str, Any]] = [] | |
| for yy in range(max(0, y - radius), min(self.height, y + radius + 1)): | |
| for xx in range(max(0, x - radius), min(self.width, x + radius + 1)): | |
| for mid in self.monster_tiles.get((xx, yy), ()): | |
| monster = self.monsters[mid] | |
| if monster["alive"]: | |
| result.append(monster) | |
| return result | |
| def relationship(self, source_id: int, target_id: int) -> dict[str, Any]: | |
| source = self.npcs[source_id] | |
| key = str(target_id) | |
| if key not in source["relationships"]: | |
| source["relationships"][key] = { | |
| "trust": 0, "fear": 0, "attraction": 0, "resentment": 0, | |
| "dependency": 0, "leverage": 0, "history": [], | |
| } | |
| return source["relationships"][key] | |
| # -- event and memory --------------------------------------------------- | |
| def emit( | |
| self, | |
| text: str, | |
| category: str, | |
| importance: int = 1, | |
| actors: Iterable[int] = (), | |
| location: str | None = None, | |
| thought: str | None = None, | |
| alternatives: list[tuple[str, float]] | None = None, | |
| ) -> None: | |
| event = { | |
| "day": self.day, | |
| "text": text, | |
| "category": category, | |
| "importance": int(importance), | |
| "actors": list(actors), | |
| "location": location, | |
| "thought": thought, | |
| "alternatives": alternatives or [], | |
| } | |
| if len(self.logs) == self.logs.maxlen: | |
| oldest = self.logs[0] | |
| self.archived_counts[oldest["category"]] += 1 | |
| self.logs.append(event) | |
| if importance >= 3: | |
| self.major_logs.append(event) | |
| for actor_id in event["actors"]: | |
| npc = self.npcs.get(actor_id) | |
| if npc is not None: | |
| npc["memories"].append({"day": self.day, "text": text, "importance": importance}) | |
| # -- simulation --------------------------------------------------------- | |
| def step(self, days: int = 1) -> None: | |
| for _ in range(max(0, int(days))): | |
| self.step_day() | |
| def step_day(self) -> None: | |
| self.day += 1 | |
| npc_ids = [nid for nid, npc in self.npcs.items() if npc["alive"]] | |
| self.rng.shuffle(npc_ids) | |
| for nid in npc_ids: | |
| npc = self.npcs.get(nid) | |
| if not npc or not npc["alive"]: | |
| continue | |
| self._npc_daily_update(npc) | |
| if npc["imprisoned_until"] > self.day: | |
| if self.rng.random() < 0.035: | |
| self.emit( | |
| f"{npc['name']} studies the routines of their confinement and quietly revises an escape plan.", | |
| "authority", 1, [nid], thought="Confinement prevents ordinary action, but it creates information about guards, schedules, and institutional weaknesses.", | |
| ) | |
| continue | |
| candidate, alternatives = self.choose_action(npc) | |
| self.execute_action(npc, candidate, alternatives) | |
| self._process_monsters() | |
| self._process_operations() | |
| self._process_response_teams() | |
| self._process_settlements() | |
| self._world_events() | |
| if self.day % 30 == 0: | |
| self._monthly_summary() | |
| if self.day % 365 == 0: | |
| self._yearly_summary() | |
| def _npc_daily_update(self, npc: dict[str, Any]) -> None: | |
| for emotion, value in list(npc["emotions"].items()): | |
| baseline = {"hope": 48, "fear": 18, "anger": 12, "loneliness": 28, "obsession": 18}[emotion] | |
| npc["emotions"][emotion] = int(value + (baseline - value) * 0.025) | |
| npc["suspicion"] = max(0, npc["suspicion"] - (1 if self.day % 4 == 0 else 0)) | |
| if npc["hp"] < npc["max_hp"] and self.rng.random() < 0.12: | |
| npc["hp"] = min(npc["max_hp"], npc["hp"] + 1) | |
| if npc["xp"] >= npc["level"] * 30: | |
| npc["xp"] -= npc["level"] * 30 | |
| npc["level"] += 1 | |
| npc["max_hp"] += self.rng.randint(3, 8) | |
| npc["hp"] = min(npc["max_hp"], npc["hp"] + 12) | |
| self.emit( | |
| f"{npc['name']} reaches level {npc['level']}, their accumulated experience becoming a durable advantage.", | |
| "advancement", 3, [npc["id"]], thought=f"Their growth came from the consequences of pursuing {npc['goal']}, not from a disconnected reward roll.", | |
| ) | |
| def choose_action(self, npc: dict[str, Any]) -> tuple[Candidate, list[Candidate]]: | |
| place = self.place_at(npc["x"], npc["y"]) | |
| locals_here = self.local_npcs(npc, 0) | |
| monsters_here = self.local_monsters(npc["x"], npc["y"], 0) | |
| candidates: list[Candidate] = [] | |
| def add(action: str, score: float, reason: str, **payload: Any) -> None: | |
| noise = self.rng.uniform(-6, 6) | |
| candidates.append(Candidate(action, score + noise, reason, payload)) | |
| health_ratio = npc["hp"] / max(1, npc["max_hp"]) | |
| add("recover", 12 + (1 - health_ratio) * 120, "injury makes survival and recovery increasingly urgent") | |
| if npc["bound_to"] is not None and npc["bound_to"] in self.npcs: | |
| controller = self.npcs[npc["bound_to"]] | |
| rel = self.relationship(npc["id"], controller["id"]) | |
| score = 25 + npc["traits"]["will"] * 0.55 + rel["resentment"] * 0.7 - rel["dependency"] * 0.35 | |
| add("escape_bond", score, "the imposed bond conflicts with private will and remembered alternatives", target=controller["id"]) | |
| if monsters_here: | |
| monster = max(monsters_here, key=lambda m: m["notoriety"] + m["level"] * 3) | |
| power_gap = npc["level"] - monster["level"] | |
| score = 42 + npc["traits"]["courage"] * 0.45 + npc["traits"]["ambition"] * 0.25 + power_gap * 5 | |
| if "monster" in npc["goal"]: | |
| score += 55 | |
| add("fight_monster", score, "a present monster offers danger, public meaning, and a test that cannot be postponed", target=monster["id"]) | |
| rivals: list[tuple[dict[str, Any], dict[str, Any]]] = [] | |
| for other in locals_here: | |
| rel = self.relationship(npc["id"], other["id"]) | |
| if rel["resentment"] > 45: | |
| rivals.append((other, rel)) | |
| if rivals: | |
| target, rel = max(rivals, key=lambda pair: pair[1]["resentment"]) | |
| add( | |
| "attack_rival", | |
| 15 + rel["resentment"] * 0.9 + npc["emotions"]["anger"] * 0.5 - rel["fear"] * 0.35, | |
| "a remembered grievance has become more salient than restraint", | |
| target=target["id"], | |
| ) | |
| if place and place["type"] == "ruin" and not place["exhausted"]: | |
| add( | |
| "delve_ruin", | |
| 32 + npc["traits"]["curiosity"] * 0.45 + npc["traits"]["greed"] * 0.25 + npc["level"] * 2, | |
| "the ruin can advance wealth, knowledge, artifacts, and the current life plan", | |
| place=place["id"], | |
| ) | |
| if place and place["type"] in {"shrine", "ruin", "academy"}: | |
| occult_bonus = 50 if npc["goal"] in {"escape mortality", "become a living god", "uncover the world's first secret"} else 0 | |
| add( | |
| "ritual", | |
| 8 + npc["traits"]["spirituality"] * 0.5 + npc["lore"] * 2 + occult_bonus, | |
| "occult practice promises progress unavailable through ordinary institutions", | |
| place=place["id"], | |
| ) | |
| if place and place["population"] > 0: | |
| add("work_trade", 24 + npc["traits"]["greed"] * 0.35 - min(20, npc["wealth"] / 15), "settled markets can convert time, reputation, and information into resources", place=place["id"]) | |
| if npc["traits"]["cunning"] + npc["traits"]["greed"] > 115: | |
| add("crime", 5 + npc["traits"]["cunning"] * 0.45 + npc["traits"]["greed"] * 0.30 - npc["suspicion"] * 0.4, "the local concentration of wealth creates a tempting asymmetric opportunity", place=place["id"]) | |
| if locals_here: | |
| target = max( | |
| locals_here, | |
| key=lambda other: self.relationship(npc["id"], other["id"])["trust"] | |
| + self.relationship(npc["id"], other["id"])["attraction"] | |
| - self.relationship(npc["id"], other["id"])["resentment"], | |
| ) | |
| loneliness = npc["emotions"]["loneliness"] | |
| add("socialize", 18 + npc["traits"]["sociability"] * 0.45 + loneliness * 0.5, "another person nearby could become an ally, lover, rival, witness, or instrument", target=target["id"]) | |
| if npc["fame"] + npc["level"] * 5 > 35: | |
| add("recruit", 8 + npc["traits"]["ambition"] * 0.35 + npc["fame"] * 0.3, "power increasingly depends on coordinated followers rather than solitary capability", target=target["id"]) | |
| if npc["corruption"] > 35 and npc["traits"]["cruelty"] > 50: | |
| vulnerable = min(locals_here, key=lambda other: other["level"] + other["traits"]["will"] / 20) | |
| add("bind", 5 + npc["corruption"] * 0.5 + npc["traits"]["cruelty"] * 0.3, "corrupt power frames another person's autonomy as an available resource", target=vulnerable["id"]) | |
| if npc["faction"] is not None: | |
| add("faction_work", 22 + npc["traits"]["ambition"] * 0.35 + npc["traits"]["loyalty"] * 0.25, "faction standing can convert influence into authority over other lives", faction=npc["faction"]) | |
| step = npc["plan"][min(npc["plan_index"], len(npc["plan"]) - 1)] | |
| add("advance_plan", 38 + npc["traits"]["ambition"] * 0.4 + npc["emotions"]["obsession"] * 0.5, f"the active plan step—{step}—organizes otherwise disconnected opportunities", step=step) | |
| add("hunt", 15 + npc["traits"]["courage"] * 0.3 + npc["traits"]["curiosity"] * 0.2, "rumours of monsters and prizes make deliberate pursuit rational") | |
| add("explore", 19 + npc["traits"]["curiosity"] * 0.55, "unknown terrain may contain information that changes the entire plan") | |
| candidates.sort(key=lambda c: c.score, reverse=True) | |
| # Softmax-like selection retains personality and uncertainty without pure roulette. | |
| top = candidates[: min(7, len(candidates))] | |
| floor = top[-1].score | |
| chosen = weighted_pick(self.rng, [(c, math.exp((c.score - floor) / 18)) for c in top]) | |
| return chosen, candidates[:4] | |
| def execute_action(self, npc: dict[str, Any], candidate: Candidate, alternatives: list[Candidate]) -> None: | |
| action = candidate.action | |
| alt_pairs = [(c.action.replace("_", " "), round(c.score, 1)) for c in alternatives if c is not candidate][:3] | |
| thought = ( | |
| f"Chosen because {candidate.reason}. Current goal: {npc['goal']}. " | |
| f"Active plan step: {npc['plan'][min(npc['plan_index'], len(npc['plan']) - 1)]}." | |
| ) | |
| handlers = { | |
| "recover": self._act_recover, | |
| "escape_bond": self._act_escape_bond, | |
| "fight_monster": self._act_fight_monster, | |
| "attack_rival": self._act_attack_rival, | |
| "delve_ruin": self._act_delve_ruin, | |
| "ritual": self._act_ritual, | |
| "work_trade": self._act_work_trade, | |
| "crime": self._act_crime, | |
| "socialize": self._act_socialize, | |
| "recruit": self._act_recruit, | |
| "bind": self._act_bind, | |
| "faction_work": self._act_faction_work, | |
| "advance_plan": self._act_advance_plan, | |
| "hunt": self._act_hunt, | |
| "explore": self._act_explore, | |
| } | |
| handlers[action](npc, candidate.payload, thought, alt_pairs) | |
| npc["last_action"] = action.replace("_", " ") | |
| # -- action handlers ---------------------------------------------------- | |
| def _act_recover(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| place = self.place_at(npc["x"], npc["y"]) | |
| amount = self.rng.randint(7, 18) + (5 if place and place["population"] else 0) | |
| npc["hp"] = min(npc["max_hp"], npc["hp"] + amount) | |
| self.emit(f"{npc['name']} withdraws to recover, restoring {amount} health and reconsidering which risks are still worth taking.", "recovery", 1, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| def _act_escape_bond(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| controller = self.npcs.get(payload.get("target")) | |
| if not controller or not controller["alive"]: | |
| npc["bound_to"] = None | |
| self.emit(f"{npc['name']}'s magical bond collapses because its controller can no longer sustain it.", "bond", 3, [npc["id"]], thought=thought, alternatives=alternatives) | |
| return | |
| rel = self.relationship(npc["id"], controller["id"]) | |
| chance = clamp((npc["traits"]["will"] + npc["level"] * 4 + rel["resentment"] - rel["dependency"] - controller["lore"] * 2) / 180, 0.06, 0.72) | |
| if self.rng.random() < chance: | |
| npc["bound_to"] = None | |
| rel["resentment"] += 18 | |
| self.emit(f"{npc['name']} breaks the bond maintained by {controller['name']}. Freedom arrives with pain, remembered dependence, and an unfinished reckoning.", "bond", 5, [npc["id"], controller["id"]], thought=thought, alternatives=alternatives) | |
| npc["xp"] += 18 | |
| else: | |
| rel["resentment"] = int(clamp(rel["resentment"] + 7, -100, 100)) | |
| rel["fear"] = int(clamp(rel["fear"] + 5, 0, 100)) | |
| self.emit(f"{npc['name']} secretly tests the bond imposed by {controller['name']}, but the escape fails. The attempt remains hidden—for now.", "bond", 2, [npc["id"]], thought=thought, alternatives=alternatives) | |
| def _combat_power(self, npc: dict[str, Any]) -> float: | |
| return npc["level"] * 9 + npc["hp"] * 0.18 + npc["traits"]["courage"] * 0.22 + len(npc["artifacts"]) * 9 | |
| def _act_fight_monster(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| monster = self.monsters.get(payload.get("target")) | |
| if not monster or not monster["alive"]: | |
| self._act_hunt(npc, {}, thought, alternatives) | |
| return | |
| npc_damage = max(1, int(self.rng.gauss(npc["level"] * 3 + npc["traits"]["courage"] / 14, 5))) | |
| monster_damage = max(1, int(self.rng.gauss(monster["level"] * 3.2 + 7, 6))) | |
| monster["hp"] -= npc_damage | |
| npc["hp"] -= monster_damage | |
| self.emit( | |
| f"{npc['name']} engages {monster['name']}. The hunter inflicts {npc_damage} harm and suffers {monster_damage}; both now possess information about the other's methods.", | |
| "combat", 3 if monster["level"] >= 5 else 2, [npc["id"]], thought=thought, alternatives=alternatives, | |
| ) | |
| if monster["hp"] <= 0: | |
| self._kill_monster(monster, [npc]) | |
| elif npc["hp"] <= 0: | |
| self._kill_npc(npc, f"was killed by {monster['name']}", killer_monster=monster) | |
| else: | |
| npc["xp"] += max(2, monster["level"]) | |
| monster["notoriety"] += 2 | |
| if self.rng.random() < 0.10 and len(monster["adaptations"]) < 4: | |
| adaptation = self.rng.choice(["learned to feign retreat", "developed armour against common weapons", "began targeting healers", "learned to conceal its spoor", "started using captives as bait"]) | |
| if adaptation not in monster["adaptations"]: | |
| monster["adaptations"].append(adaptation) | |
| self.emit(f"{monster['name']} survives and adapts: it {adaptation}.", "monster", 4) | |
| def _act_attack_rival(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| target = self.npcs.get(payload.get("target")) | |
| if not target or not target["alive"]: | |
| return | |
| a = max(1, int(self.rng.gauss(npc["level"] * 2.6 + npc["traits"]["cunning"] / 18, 5))) | |
| b = max(1, int(self.rng.gauss(target["level"] * 2.4 + target["traits"]["courage"] / 20, 5))) | |
| target["hp"] -= a | |
| npc["hp"] -= b | |
| self.relationship(npc["id"], target["id"])["resentment"] = 100 | |
| self.relationship(target["id"], npc["id"])["resentment"] = 100 | |
| self.emit(f"{npc['name']} acts on a deep grudge against {target['name']}. Their clash deals {a} harm to {target['name']} and {b} to {npc['name']}.", "combat", 4, [npc["id"], target["id"]], thought=thought, alternatives=alternatives) | |
| if target["hp"] <= 0: | |
| self._kill_npc(target, f"was killed by {npc['name']}", killer=npc) | |
| if npc["alive"] and npc["hp"] <= 0: | |
| self._kill_npc(npc, f"was killed by {target['name']}", killer=target) | |
| def _act_delve_ruin(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| place = self.places.get(payload.get("place")) | |
| if not place or place["exhausted"]: | |
| self._act_explore(npc, {}, thought, alternatives) | |
| return | |
| danger = max(2, place["depth"] * 2 - npc["level"]) | |
| harm = max(0, int(self.rng.gauss(danger, 5))) | |
| loot = min(place["treasure"], self.rng.randint(8, 28) + npc["level"] * 2) | |
| place["treasure"] -= loot | |
| npc["wealth"] += loot | |
| npc["hp"] -= harm | |
| npc["xp"] += 3 + place["depth"] | |
| place["history"].append(f"Day {self.day}: {npc['name']} removed treasure worth {loot} crowns.") | |
| found_artifact = self.rng.random() < 0.025 + place["depth"] * 0.008 | |
| extra = "" | |
| if found_artifact: | |
| artifact = self._create_artifact(npc, place) | |
| extra = f" They also recover {artifact['name']}, whose effects are not yet fully understood." | |
| if place["treasure"] <= 0: | |
| place["exhausted"] = True | |
| place["state"] = "excavated" | |
| extra += " The ruin's accessible wealth is now exhausted; whatever remains will require a new method or catastrophe to reveal." | |
| self.emit(f"{npc['name']} penetrates deeper into {place['name']}, returning with relics worth {loot} crowns and suffering {harm} harm.{extra}", "exploration", 3 if found_artifact or place["exhausted"] else 2, [npc["id"]], place["name"], thought, alternatives) | |
| if npc["hp"] <= 0: | |
| self._kill_npc(npc, f"died in {place['name']}") | |
| def _create_artifact(self, npc: dict[str, Any], place: dict[str, Any]) -> dict[str, Any]: | |
| aid = self.next_artifact_id | |
| self.next_artifact_id += 1 | |
| name = self.rng.choice([ | |
| "The Knife That Remembers Its Victims", "Crown of the Second City", | |
| "A Bell Made from Tomorrow", "The Velvet Compass", "Saint-Bone Engine", | |
| "Mirror of the Unchosen Life", "The Debt-Eater", "The Red Testament", | |
| ]) | |
| artifact = { | |
| "id": aid, "name": name, "owner": npc["id"], "origin": place["id"], | |
| "power": self.rng.randint(8, 28), "corruption": self.rng.randint(1, 12), | |
| "sentient": self.rng.random() < 0.20, | |
| } | |
| self.artifacts[aid] = artifact | |
| npc["artifacts"].append(aid) | |
| npc["corruption"] = int(clamp(npc["corruption"] + artifact["corruption"], 0, 100)) | |
| return artifact | |
| def _act_ritual(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| place = self.places.get(payload.get("place")) or self.place_at(npc["x"], npc["y"]) | |
| npc["lore"] += self.rng.randint(1, 3) | |
| npc["corruption"] = int(clamp(npc["corruption"] + self.rng.randint(1, 6), 0, 100)) | |
| if npc["patron"] is None and npc["lore"] >= 8 and self.rng.random() < 0.16: | |
| patron_name, domain = self.rng.choice(PATRONS) | |
| npc["patron"] = patron_name | |
| npc["occult_stage"] = 1 | |
| npc["vessel_compatibility"] = self.rng.randint(8, 18) | |
| self.emit(f"{npc['name']} establishes first contact with {patron_name}, power of {domain}. The answering presence is intimate, patient, and unmistakably real.", "occult", 5, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| return | |
| if npc["patron"] is not None: | |
| npc["vessel_compatibility"] = int(clamp(npc["vessel_compatibility"] + self.rng.randint(2, 8), 0, 100)) | |
| thresholds = [0, 0, 25, 45, 68, 90] | |
| next_stage = npc["occult_stage"] + 1 | |
| if next_stage <= 5 and npc["vessel_compatibility"] >= thresholds[next_stage]: | |
| npc["occult_stage"] = next_stage | |
| stage_text = { | |
| 2: "accepts a supernatural mark that changes how mirrors and dreams respond to them", | |
| 3: "seals a pact whose exact price has been deliberately left ambiguous", | |
| 4: "becomes a true vessel candidate; nearby sleepers begin sharing fragments of their thoughts", | |
| 5: "survives partial incarnation. The patron can now act through them, but the mortal mind has not disappeared", | |
| }[next_stage] | |
| self.emit(f"{npc['name']} {stage_text}. This occult threshold is irreversible and will not repeat as a daily notification.", "occult", 5, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| if next_stage == 5: | |
| npc["titles"].append(f"Vessel of {npc['patron']}") | |
| npc["fame"] += 80 | |
| return | |
| mishap = self.rng.random() < max(0.02, (npc["corruption"] - npc["traits"]["discipline"]) / 280) | |
| if mishap: | |
| harm = self.rng.randint(5, 22) | |
| npc["hp"] -= harm | |
| self.emit(f"{npc['name']}'s occult working tears sideways. They suffer {harm} harm, and something now knows the path back to them.", "occult", 4, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| if npc["hp"] <= 0: | |
| self._kill_npc(npc, "was consumed by a failed ritual") | |
| else: | |
| self.emit(f"{npc['name']} completes an occult working, increasing lore to {npc['lore']} and corruption to {npc['corruption']}. The gain is real, but so is the accumulating price.", "occult", 2, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| def _act_work_trade(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| place = self.places.get(payload.get("place")) or self.place_at(npc["x"], npc["y"]) | |
| base = self.rng.randint(4, 14) + npc["level"] | |
| profit = max(1, int(base * (place["prosperity"] / 60 if place else 1))) | |
| npc["wealth"] += profit | |
| npc["xp"] += 1 | |
| if place: | |
| place["prosperity"] = int(clamp(place["prosperity"] + self.rng.choice([-1, 0, 1]), 0, 100)) | |
| self.emit(f"{npc['name']} trades labour, goods, and information in {place['name'] if place else 'a roadside camp'}, clearing {profit} crowns and strengthening local contacts.", "economy", 1, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| def _act_crime(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| place = self.places.get(payload.get("place")) or self.place_at(npc["x"], npc["y"]) | |
| take = self.rng.randint(8, 30) + npc["level"] | |
| npc["wealth"] += take | |
| npc["infamy"] += self.rng.randint(2, 7) | |
| npc["suspicion"] += self.rng.randint(6, 18) | |
| self.emit(f"{npc['name']} commits a calculated crime in {place['name'] if place else 'the wilderness'}, taking {take} crowns and leaving frightened, imperfect witnesses.", "crime", 2, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| security = place["security"] if place else 10 | |
| arrest_chance = clamp((npc["suspicion"] + security - npc["traits"]["cunning"]) / 220, 0.01, 0.45) | |
| if self.rng.random() < arrest_chance: | |
| days = self.rng.randint(5, 22) | |
| npc["imprisoned_until"] = self.day + days | |
| self.emit(f"Authorities arrest {npc['name']} after a coordinated investigation. Their plans are interrupted for {days} days, but confinement may produce new grudges and contacts.", "authority", 4, [npc["id"]], place["name"] if place else None) | |
| def _act_socialize(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| other = self.npcs.get(payload.get("target")) | |
| if not other or not other["alive"]: | |
| return | |
| rel = self.relationship(npc["id"], other["id"]) | |
| reciprocal = self.relationship(other["id"], npc["id"]) | |
| compatibility = 50 - abs(npc["traits"]["empathy"] - other["traits"]["empathy"]) * 0.25 | |
| trust_gain = max(-2, int(self.rng.gauss(3 + compatibility / 25, 2))) | |
| attraction_gain = max(0, int(self.rng.gauss((npc["traits"]["sociability"] + other["traits"]["sociability"]) / 70, 2))) | |
| rel["trust"] = int(clamp(rel["trust"] + trust_gain, -100, 100)) | |
| reciprocal["trust"] = int(clamp(reciprocal["trust"] + max(0, trust_gain - 1), -100, 100)) | |
| rel["attraction"] = int(clamp(rel["attraction"] + attraction_gain, 0, 100)) | |
| npc["emotions"]["loneliness"] = max(0, npc["emotions"]["loneliness"] - 6) | |
| rel["history"].append(f"Day {self.day}: discussed ambitions and exchanged selectively honest fears.") | |
| self.emit(f"{npc['name']} spends the day with {other['name']}. Trust changes by {trust_gain:+d}; attraction changes by {attraction_gain:+d}, while neither can know how much the other concealed.", "relationship", 1, [npc["id"], other["id"]], thought=thought, alternatives=alternatives) | |
| if rel["attraction"] >= 70 and "Infatuated" not in npc["titles"]: | |
| npc["titles"].append("Infatuated") | |
| npc["target_npc"] = other["id"] | |
| npc["emotions"]["obsession"] = min(100, npc["emotions"]["obsession"] + 25) | |
| self.emit(f"{npc['name']} becomes deeply infatuated with {other['name']}, beginning to reinterpret unrelated plans around remaining close.", "relationship", 4, [npc["id"], other["id"]]) | |
| def _act_recruit(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| other = self.npcs.get(payload.get("target")) | |
| if not other or not other["alive"] or other["id"] in npc["followers"]: | |
| return | |
| rel = self.relationship(other["id"], npc["id"]) | |
| chance = clamp((npc["fame"] + npc["level"] * 6 + npc["wealth"] / 15 + rel["trust"] - other["traits"]["ambition"] * 0.3) / 140, 0.03, 0.78) | |
| if self.rng.random() < chance: | |
| npc["followers"].add(other["id"]) | |
| rel["dependency"] = int(clamp(rel["dependency"] + 12, 0, 100)) | |
| self.emit(f"{npc['name']} persuades {other['name']} to support their cause. The retinue now contains {len(npc['followers'])} named protagonist-class followers, each retaining private goals.", "organization", 3, [npc["id"], other["id"]], thought=thought, alternatives=alternatives) | |
| else: | |
| rel["trust"] -= 2 | |
| self.emit(f"{npc['name']} tries to recruit {other['name']}, but the offer fails to outweigh the life {other['name']} already intends to build.", "organization", 1, [npc["id"], other["id"]], thought=thought, alternatives=alternatives) | |
| def _act_bind(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| other = self.npcs.get(payload.get("target")) | |
| if not other or not other["alive"] or other["bound_to"] is not None: | |
| return | |
| chance = clamp((npc["lore"] * 4 + npc["corruption"] + npc["level"] * 4 - other["traits"]["will"] - other["level"] * 3) / 130 + 0.25, 0.02, 0.75) | |
| if self.rng.random() < chance: | |
| other["bound_to"] = npc["id"] | |
| rel = self.relationship(other["id"], npc["id"]) | |
| rel["fear"] = int(clamp(rel["fear"] + 30, 0, 100)) | |
| rel["resentment"] = int(clamp(rel["resentment"] + 25, -100, 100)) | |
| rel["dependency"] = int(clamp(rel["dependency"] + 15, 0, 100)) | |
| self.emit(f"{npc['name']} imposes a magical bond on {other['name']}. Compliance, fear, dependency, and private resistance now coexist rather than collapsing into a single loyalty score.", "bond", 5, [npc["id"], other["id"]], thought=thought, alternatives=alternatives) | |
| else: | |
| other["emotions"]["fear"] = min(100, other["emotions"]["fear"] + 15) | |
| self.relationship(other["id"], npc["id"])["resentment"] += 20 | |
| self.emit(f"{npc['name']} attempts to bind {other['name']}, but the working fails. The intended victim now possesses both warning and motive.", "bond", 4, [npc["id"], other["id"]], thought=thought, alternatives=alternatives) | |
| def _act_faction_work(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| faction = self.factions.get(npc["faction"]) | |
| if not faction: | |
| return | |
| gain = self.rng.randint(2, 8) + npc["traits"]["ambition"] // 25 | |
| npc["standing"] += gain | |
| faction["power"] = int(clamp(faction["power"] + self.rng.choice([-1, 0, 1, 2]), 0, 200)) | |
| self.emit(f"{npc['name']} advances {faction['name']}'s agenda to {faction['agenda']}, gaining {gain} standing through a specific web of favours, witnesses, and obligations.", "faction", 2, [npc["id"]], thought=thought, alternatives=alternatives) | |
| threshold = (npc["rank"] + 1) * 45 | |
| if npc["standing"] >= threshold and npc["rank"] < len(RANK_NAMES) - 1: | |
| npc["rank"] += 1 | |
| self.emit(f"{npc['name']} is promoted to {RANK_NAMES[npc['rank']]} in {faction['name']}. Their decisions now redirect money, careers, and danger for other people.", "faction", 4, [npc["id"]]) | |
| if npc["rank"] >= 6 and (faction["leader"] is None or self.rng.random() < 0.25): | |
| old = faction["leader"] | |
| faction["leader"] = npc["id"] | |
| self.emit(f"{npc['name']} takes effective control of {faction['name']}, displacing {self.npcs[old]['name'] if old in self.npcs else 'an exhausted predecessor'}.", "faction", 5, [npc["id"]] + ([old] if old in self.npcs else [])) | |
| def _act_advance_plan(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| step = npc["plan"][min(npc["plan_index"], len(npc["plan"]) - 1)] | |
| progress = self.rng.randint(5, 16) + npc["traits"]["discipline"] // 18 + npc["level"] | |
| # Context makes plans less abstract. | |
| place = self.place_at(npc["x"], npc["y"]) | |
| if place and any(word in step for word in ["land", "city", "headquarters", "estate", "stronghold"]): | |
| progress += 8 | |
| if npc["faction"] is not None and any(word in step for word in ["coalition", "faction", "succession", "authority"]): | |
| progress += 7 | |
| if npc["artifacts"] and "artifact" in step: | |
| progress += 10 | |
| npc["plan_progress"] += progress | |
| if npc["target_place"] is None and self.rng.random() < 0.35: | |
| target = self.nearest_place(npc["x"], npc["y"], ["city", "ruin", "fort", "shrine"]) | |
| npc["target_place"] = target["id"] if target else None | |
| if npc["plan_progress"] >= 100: | |
| npc["plan_progress"] -= 100 | |
| npc["plan_index"] += 1 | |
| self.emit(f"{npc['name']} completes the plan step “{step}” in pursuit of {npc['goal']}. The accomplishment changes what actions are now possible.", "plan", 4, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| npc["xp"] += 18 | |
| npc["fame"] += 6 | |
| if npc["plan_index"] >= len(npc["plan"]): | |
| self._complete_life_goal(npc) | |
| else: | |
| self.emit(f"{npc['name']} works directly on “{step},” advancing it to {npc['plan_progress']}%. The effort produces contacts, partial commitments, and risks rather than a generic progress point alone.", "plan", 2, [npc["id"]], place["name"] if place else None, thought, alternatives) | |
| def _complete_life_goal(self, npc: dict[str, Any]) -> None: | |
| goal = npc["goal"] | |
| npc["titles"].append(f"Fulfilled: {goal.title()}") | |
| npc["fame"] += 100 | |
| npc["level"] += 2 | |
| if goal == "found a sovereign city": | |
| x, y = npc["x"], npc["y"] | |
| pid = self.next_place_id | |
| self.next_place_id += 1 | |
| name = f"{npc['name'].split()[0]}'s {self.rng.choice(['Haven', 'Crown', 'Refuge', 'Freehold'])}" | |
| self.places[pid] = { | |
| "id": pid, "name": name, "type": "city", "x": x, "y": y, | |
| "population": self.rng.randint(600, 1800), "security": 45, | |
| "prosperity": 35, "damage": 0, "state": "newly founded", | |
| "faction": npc["faction"], "treasure": 0, "depth": 0, | |
| "exhausted": False, "history": [f"Founded by {npc['name']} on day {self.day}."], | |
| "price_index": 1.2, | |
| } | |
| self.place_tiles[(x, y)].append(pid) | |
| self.emit(f"{npc['name']} fulfills the life ambition to {goal}. This is a permanent historical accomplishment, not a repeating daily event.", "legend", 5, [npc["id"]]) | |
| new_goal = self.rng.choice([g for g in GOALS if g != goal]) | |
| npc["goal"] = new_goal | |
| npc["plan"] = list(GOALS[new_goal]) | |
| npc["plan_index"] = 0 | |
| npc["plan_progress"] = 0 | |
| self.emit(f"Having achieved what once defined them, {npc['name']} adopts a new life ambition: {new_goal}.", "plan", 4, [npc["id"]]) | |
| def _act_hunt(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| living = [m for m in self.monsters.values() if m["alive"]] | |
| if not living: | |
| self._act_explore(npc, {}, thought, alternatives) | |
| return | |
| target = min(living, key=lambda m: abs(m["x"] - npc["x"]) + abs(m["y"] - npc["y"]) - m["notoriety"] * 0.08) | |
| npc["target_monster"] = target["id"] | |
| self._travel_toward(npc, target["x"], target["y"]) | |
| self.emit(f"{npc['name']} follows reports, spoor, and contradictory witness accounts toward {target['name']}. The hunt now has a named target rather than a generic monster flag.", "monster", 2, [npc["id"]], thought=thought, alternatives=alternatives) | |
| def _act_explore(self, npc: dict[str, Any], payload: dict[str, Any], thought: str, alternatives: list[tuple[str, float]]) -> None: | |
| dx = self.rng.choice([-1, 0, 1]) | |
| dy = self.rng.choice([-1, 0, 1]) | |
| x = int(clamp(npc["x"] + dx, 0, self.width - 1)) | |
| y = int(clamp(npc["y"] + dy, 0, self.height - 1)) | |
| if self.terrain[y][x] == "water" and self.rng.random() < 0.8: | |
| x, y = npc["x"], npc["y"] | |
| self.move_npc(npc, x, y) | |
| biome = self.terrain[y][x] | |
| discovery = self.place_at(x, y) | |
| npc["xp"] += 1 | |
| if discovery and self.rng.random() < 0.25: | |
| text = f"{npc['name']} explores the {biome} and discovers new evidence about {discovery['name']}, changing the reliability of local rumours." | |
| importance = 2 | |
| else: | |
| text = f"{npc['name']} explores the {biome}, mapping routes, water, shelter, tracks, and signs of recent human intention." | |
| importance = 1 | |
| self.emit(text, "exploration", importance, [npc["id"]], discovery["name"] if discovery else None, thought, alternatives) | |
| def _travel_toward(self, npc: dict[str, Any], tx: int, ty: int) -> None: | |
| dx = 0 if tx == npc["x"] else 1 if tx > npc["x"] else -1 | |
| dy = 0 if ty == npc["y"] else 1 if ty > npc["y"] else -1 | |
| options = [(npc["x"] + dx, npc["y"]), (npc["x"], npc["y"] + dy), (npc["x"] + dx, npc["y"] + dy)] | |
| options = [(int(clamp(x, 0, self.width - 1)), int(clamp(y, 0, self.height - 1))) for x, y in options] | |
| options = [pos for pos in options if self.terrain[pos[1]][pos[0]] != "water"] or [(npc["x"], npc["y"])] | |
| x, y = min(options, key=lambda pos: abs(tx - pos[0]) + abs(ty - pos[1])) | |
| self.move_npc(npc, x, y) | |
| # -- deaths, monsters, crises ------------------------------------------ | |
| def _kill_npc(self, npc: dict[str, Any], cause: str, killer: dict[str, Any] | None = None, killer_monster: dict[str, Any] | None = None) -> None: | |
| if not npc["alive"]: | |
| return | |
| npc["alive"] = False | |
| npc["hp"] = 0 | |
| self.npc_tiles[(npc["x"], npc["y"])].discard(npc["id"]) | |
| if npc["faction"] is not None: | |
| self.factions[npc["faction"]]["members"].discard(npc["id"]) | |
| freed = [] | |
| for other in self.npcs.values(): | |
| if other["alive"] and other["bound_to"] == npc["id"]: | |
| other["bound_to"] = None | |
| freed.append(other["id"]) | |
| if killer: | |
| killer["kills"] += 1 | |
| killer["xp"] += 15 + npc["level"] * 3 | |
| killer["fame"] += max(1, npc["fame"] // 10) | |
| killer["infamy"] += 6 | |
| if killer_monster: | |
| killer_monster["kills"] += 1 | |
| killer_monster["notoriety"] += 10 | |
| text = f"{npc['name']} {cause}. Their ambitions end on day {self.day}." | |
| if freed: | |
| text += f" The death collapses {len(freed)} active magical bond(s), freeing people whose feelings about that freedom remain complicated." | |
| self.emit(text, "death", 5, [npc["id"]] + ([killer["id"]] if killer else [])) | |
| def _kill_monster(self, monster: dict[str, Any], hunters: list[dict[str, Any]]) -> None: | |
| if not monster["alive"]: | |
| return | |
| monster["alive"] = False | |
| monster["hp"] = 0 | |
| self.monster_tiles[(monster["x"], monster["y"])].discard(monster["id"]) | |
| names = ", ".join(h["name"] for h in hunters[:5]) | |
| for hunter in hunters: | |
| hunter["monster_kills"] += 1 | |
| hunter["xp"] += 12 + monster["level"] * 5 | |
| hunter["fame"] += 8 + monster["notoriety"] // 2 | |
| hunter["wealth"] += 10 + monster["level"] * 4 | |
| self.emit(f"{names} kill {monster['name']}, a level-{monster['level']} threat with {monster['kills']} recorded kills. The victory creates trophies, disputed credit, and a story other people will retell inaccurately.", "legend", 5, [h["id"] for h in hunters]) | |
| self.response_teams.pop(monster["id"], None) | |
| def _process_monsters(self) -> None: | |
| mids = [mid for mid, m in self.monsters.items() if m["alive"]] | |
| self.rng.shuffle(mids) | |
| for mid in mids: | |
| monster = self.monsters[mid] | |
| locals_here = [self.npcs[nid] for nid in self.npc_tiles.get((monster["x"], monster["y"]), ()) if self.npcs[nid]["alive"]] | |
| if locals_here and self.rng.random() < 0.24: | |
| victim = min(locals_here, key=lambda n: n["level"] + n["hp"] / 20) | |
| harm = self.rng.randint(8, 22) + monster["level"] * 2 | |
| victim["hp"] -= harm | |
| self.emit(f"{monster['name']} attacks {victim['name']}, inflicting {harm} harm and learning how this kind of prey responds under pressure.", "monster", 3, [victim["id"]]) | |
| if victim["hp"] <= 0: | |
| self._kill_npc(victim, f"was killed by {monster['name']}", killer_monster=monster) | |
| continue | |
| settlement = self.nearest_place(monster["x"], monster["y"], ["city", "town", "fort", "market", "academy"]) | |
| if settlement is None: | |
| continue | |
| distance = abs(settlement["x"] - monster["x"]) + abs(settlement["y"] - monster["y"]) | |
| if distance == 0 and self.rng.random() < 0.20: | |
| self._monster_ravage(monster, settlement) | |
| elif self.rng.random() < 0.55: | |
| dx = 0 if settlement["x"] == monster["x"] else 1 if settlement["x"] > monster["x"] else -1 | |
| dy = 0 if settlement["y"] == monster["y"] else 1 if settlement["y"] > monster["y"] else -1 | |
| self.move_monster(monster, monster["x"] + dx, monster["y"] + dy) | |
| def _monster_ravage(self, monster: dict[str, Any], place: dict[str, Any]) -> None: | |
| casualties = min(place["population"], self.rng.randint(4, 18) * max(1, monster["level"]) // 2) | |
| place["population"] -= casualties | |
| damage = self.rng.randint(4, 14) + monster["level"] | |
| place["damage"] = int(clamp(place["damage"] + damage, 0, 100)) | |
| place["security"] = int(clamp(place["security"] - damage // 3, 0, 100)) | |
| place["prosperity"] = int(clamp(place["prosperity"] - damage // 2, 0, 100)) | |
| monster["notoriety"] += 4 + casualties // 20 | |
| monster["last_target"] = place["id"] | |
| state = "alarmed" | |
| if place["damage"] >= 25: | |
| state = "fortified" | |
| if place["damage"] >= 50: | |
| state = "besieged" | |
| if place["damage"] >= 75: | |
| state = "evacuating" | |
| if place["population"] <= 0 or place["damage"] >= 100: | |
| state = "destroyed" | |
| place["state"] = state | |
| self.emit(f"{monster['name']} ravages {place['name']}, killing or scattering {casualties} people. The settlement enters the state “{state}”; prices, security, migration, and faction priorities change with it.", "crisis", 5, location=place["name"]) | |
| self._ensure_response_team(monster, place) | |
| def _ensure_response_team(self, monster: dict[str, Any], place: dict[str, Any]) -> None: | |
| if monster["id"] in self.response_teams: | |
| team = self.response_teams[monster["id"]] | |
| team["urgency"] += 1 | |
| return | |
| candidates = [ | |
| n for n in self.npcs.values() if n["alive"] and n["imprisoned_until"] <= self.day | |
| and abs(n["x"] - place["x"]) + abs(n["y"] - place["y"]) <= 18 | |
| ] | |
| candidates.sort(key=lambda n: self._combat_power(n) + n["fame"] * 0.3 + (35 if "monster" in n["goal"] else 0), reverse=True) | |
| members = [n["id"] for n in candidates[: min(8, max(3, 2 + monster["level"] // 2))]] | |
| if not members: | |
| return | |
| team = { | |
| "monster": monster["id"], "place": place["id"], "members": members, | |
| "stage": "assembling", "created": self.day, "urgency": 1, | |
| } | |
| self.response_teams[monster["id"]] = team | |
| for nid in members: | |
| self.npcs[nid]["target_monster"] = monster["id"] | |
| names = ", ".join(self.npcs[n]["name"] for n in members[:4]) | |
| self.emit(f"A real response team forms against {monster['name']}: {names}{' and others' if len(members) > 4 else ''}. It has named members, travel time, injuries, and the possibility of failure.", "organization", 4, members, place["name"]) | |
| def _process_response_teams(self) -> None: | |
| for mid, team in list(self.response_teams.items()): | |
| monster = self.monsters.get(mid) | |
| if not monster or not monster["alive"]: | |
| self.response_teams.pop(mid, None) | |
| continue | |
| living_members = [self.npcs[nid] for nid in team["members"] if nid in self.npcs and self.npcs[nid]["alive"]] | |
| if not living_members: | |
| self.response_teams.pop(mid, None) | |
| continue | |
| arrived: list[dict[str, Any]] = [] | |
| for npc in living_members: | |
| if npc["imprisoned_until"] > self.day: | |
| continue | |
| distance = abs(npc["x"] - monster["x"]) + abs(npc["y"] - monster["y"]) | |
| if distance == 0: | |
| arrived.append(npc) | |
| elif self.rng.random() < 0.65: | |
| self._travel_toward(npc, monster["x"], monster["y"]) | |
| if len(arrived) >= max(2, len(living_members) // 2): | |
| attack = sum(self._combat_power(n) for n in arrived) * self.rng.uniform(0.45, 0.8) | |
| defense = monster["level"] * 32 + monster["hp"] + len(monster["adaptations"]) * 18 | |
| if attack >= defense: | |
| self._kill_monster(monster, arrived) | |
| else: | |
| casualties = self.rng.sample(arrived, k=min(len(arrived), max(1, monster["level"] // 5))) | |
| for npc in casualties: | |
| harm = self.rng.randint(12, 30) + monster["level"] | |
| npc["hp"] -= harm | |
| if npc["hp"] <= 0: | |
| self._kill_npc(npc, f"was killed during the failed expedition against {monster['name']}", killer_monster=monster) | |
| monster["hp"] = max(1, monster["hp"] - int(attack * 0.25)) | |
| monster["notoriety"] += 8 | |
| team["stage"] = "broken" | |
| self.emit(f"The response team attacks {monster['name']} and fails. Survivors retreat with tactical knowledge, injuries, disputed blame, and a more dangerous enemy.", "crisis", 5, [n["id"] for n in arrived]) | |
| def _process_settlements(self) -> None: | |
| for place in self.places.values(): | |
| if place["population"] <= 0: | |
| continue | |
| if place["state"] in {"stable", "newly founded"}: | |
| if self.day % 30 == 0: | |
| growth = max(0, int(place["population"] * self.rng.uniform(0.0002, 0.0015))) | |
| place["population"] += growth | |
| elif place["state"] == "alarmed": | |
| place["security"] = int(clamp(place["security"] + 1, 0, 100)) | |
| if place["damage"] > 0 and self.day % 5 == 0: | |
| place["damage"] -= 1 | |
| elif place["state"] in {"fortified", "besieged", "evacuating"}: | |
| place["price_index"] = round(clamp(place["price_index"] + 0.01, 0.5, 3.0), 2) | |
| if place["state"] == "evacuating" and self.day % 3 == 0: | |
| place["population"] = max(0, place["population"] - max(1, place["population"] // 200)) | |
| if place["damage"] < 15 and place["state"] not in {"stable", "newly founded"}: | |
| place["state"] = "recovering" | |
| if place["state"] == "recovering" and self.day % 4 == 0: | |
| place["damage"] = max(0, place["damage"] - 2) | |
| place["prosperity"] = min(100, place["prosperity"] + 1) | |
| if place["damage"] == 0: | |
| place["state"] = "stable" | |
| self.emit(f"{place['name']} completes its recovery from crisis. Memorials, debts, demographic losses, and changed institutions remain.", "settlement", 4, location=place["name"]) | |
| # -- factions and world events ----------------------------------------- | |
| def _process_operations(self) -> None: | |
| if self.day % 12 == 0: | |
| faction = self.rng.choice(list(self.factions.values())) | |
| rivals = [fid for fid, value in faction["relations"].items() if value < 5] | |
| target_faction = self.rng.choice(rivals) if rivals else self.rng.choice([fid for fid in self.factions if fid != faction["id"]]) | |
| members = [self.npcs[n] for n in faction["members"] if n in self.npcs and self.npcs[n]["alive"]] | |
| members.sort(key=lambda n: n["standing"] + n["traits"]["cunning"], reverse=True) | |
| agents = [n["id"] for n in members[: min(5, len(members))]] | |
| operation = { | |
| "faction": faction["id"], "target": target_faction, | |
| "agents": agents, "due": self.day + self.rng.randint(5, 14), | |
| "kind": self.rng.choice(["seize a route", "expose a scandal", "steal a relic ledger", "suborn an official", "sabotage a response team", "force a treaty"]), | |
| } | |
| self.operations.append(operation) | |
| self.emit(f"{faction['name']} begins an operation to {operation['kind']} against {self.factions[target_faction]['name']}. Named agents carry the plan, and their personal loyalties may distort it.", "faction", 3, agents) | |
| remaining = [] | |
| for operation in self.operations: | |
| if operation["due"] > self.day: | |
| remaining.append(operation) | |
| continue | |
| faction = self.factions[operation["faction"]] | |
| target = self.factions[operation["target"]] | |
| agents = [self.npcs[n] for n in operation["agents"] if n in self.npcs and self.npcs[n]["alive"]] | |
| strength = faction["power"] + sum(a["traits"]["cunning"] + a["level"] * 4 for a in agents) / max(1, len(agents)) | |
| resistance = target["power"] + self.rng.randint(0, 80) | |
| if strength > resistance: | |
| faction["power"] = int(clamp(faction["power"] + 5, 0, 200)) | |
| target["power"] = int(clamp(target["power"] - 4, 0, 200)) | |
| faction["victories"] += 1 | |
| target["defeats"] += 1 | |
| for agent in agents: | |
| agent["standing"] += 8 | |
| self.emit(f"{faction['name']} successfully completes its operation to {operation['kind']} against {target['name']}. Routes, officials, or records actually change hands.", "faction", 5, [a["id"] for a in agents]) | |
| else: | |
| faction["defeats"] += 1 | |
| target["victories"] += 1 | |
| for agent in agents: | |
| agent["suspicion"] += 8 | |
| self.emit(f"{faction['name']}'s attempt to {operation['kind']} against {target['name']} fails, creating exposed agents, blame, and opportunities for internal betrayal.", "faction", 4, [a["id"] for a in agents]) | |
| self.operations = remaining | |
| def _world_events(self) -> None: | |
| # New threats keep ecology from becoming empty. | |
| living_monsters = sum(1 for m in self.monsters.values() if m["alive"]) | |
| desired = max(20, len(self.npcs) // 6) | |
| if living_monsters < desired and self.rng.random() < 0.12: | |
| self._spawn_monster() | |
| if self.rng.random() < 0.0035: | |
| self._wild_anomaly() | |
| if self.day % 90 == 0 and self.rng.random() < 0.7: | |
| self._migration_or_refugees() | |
| def _spawn_monster(self) -> None: | |
| mid = self.next_monster_id | |
| self.next_monster_id += 1 | |
| x, y = self._random_land() | |
| level = self.rng.randint(2, max(4, 3 + self.day // 300)) | |
| monster = { | |
| "id": mid, "name": f"{self.rng.choice(MONSTER_BASES)} {self.rng.choice(MONSTER_EPITHETS)}", | |
| "x": x, "y": y, "level": level, "hp": 45 + level * 18, | |
| "max_hp": 45 + level * 18, "notoriety": 0, "alive": True, | |
| "kills": 0, "adaptations": [], | |
| "temperament": self.rng.choice(["territorial", "predatory", "migratory", "cunning", "cultivated"]), | |
| "last_target": None, | |
| } | |
| self.monsters[mid] = monster | |
| self.monster_tiles[(x, y)].add(mid) | |
| self.emit(f"A new threat emerges: {monster['name']} is sighted in the {self.terrain[y][x]}. No institution yet agrees on what it wants.", "monster", 4) | |
| def _wild_anomaly(self) -> None: | |
| description = self.rng.choice(WILD_ANOMALIES) | |
| place = self.rng.choice(list(self.places.values())) | |
| anomaly = {"day": self.day, "description": description, "place": place["id"]} | |
| self.anomalies.append(anomaly) | |
| place["state"] = "anomalous" | |
| place["prosperity"] = int(clamp(place["prosperity"] + self.rng.randint(-18, 18), 0, 100)) | |
| nearby = [self.npcs[n] for n in self.npc_tiles.get((place["x"], place["y"]), ()) if self.npcs[n]["alive"]] | |
| for npc in nearby[:8]: | |
| npc["lore"] += 1 | |
| npc["emotions"]["obsession"] = min(100, npc["emotions"]["obsession"] + 7) | |
| self.emit(f"At {place['name']}, {description}. This anomaly persists as a location state and can alter later plans, economies, and relationships.", "anomaly", 5, [n["id"] for n in nearby[:8]], place["name"]) | |
| def _migration_or_refugees(self) -> None: | |
| crises = [p for p in self.places.values() if p["state"] in {"besieged", "evacuating", "destroyed"} and p["population"] > 0] | |
| safe_places = [p for p in self.places.values() if p["population"] > 0 and p["state"] == "stable"] | |
| if not crises or not safe_places: | |
| return | |
| origin = self.rng.choice(crises) | |
| destination = min(safe_places, key=lambda p: abs(p["x"] - origin["x"]) + abs(p["y"] - origin["y"])) | |
| refugees = min(origin["population"], max(20, origin["population"] // 20)) | |
| origin["population"] -= refugees | |
| destination["population"] += int(refugees * 0.8) | |
| destination["prosperity"] = max(0, destination["prosperity"] - 2) | |
| self.emit(f"A refugee movement of roughly {refugees} people leaves {origin['name']} for {destination['name']}. The destination gains labour, stories, political pressure, and unresolved claims.", "settlement", 4, location=destination["name"]) | |
| def _monthly_summary(self) -> None: | |
| living = sum(1 for n in self.npcs.values() if n["alive"]) | |
| threats = sum(1 for m in self.monsters.values() if m["alive"]) | |
| crises = sum(1 for p in self.places.values() if p["state"] not in {"stable", "unclaimed", "excavated"}) | |
| self.emit(f"Month {self.day // 30}: {living} protagonist NPCs remain alive, {threats} monsters are active, {crises} places are in unusual or crisis states, and {len(self.response_teams)} response teams are operating.", "summary", 3) | |
| def _yearly_summary(self) -> None: | |
| legends = sorted([n for n in self.npcs.values() if n["alive"]], key=lambda n: n["fame"] + n["level"] * 5, reverse=True)[:3] | |
| names = ", ".join(n["name"] for n in legends) | |
| self.emit(f"Year {self.day // 365} closes. The most visible living figures are {names}; public fame still conceals the private motives that produced it.", "summary", 5, [n["id"] for n in legends]) | |
| # -- serialization ------------------------------------------------------ | |
| def to_state(self) -> dict[str, Any]: | |
| def npc_state(n: dict[str, Any]) -> dict[str, Any]: | |
| result = dict(n) | |
| result["memories"] = list(n["memories"]) | |
| result["followers"] = list(n["followers"]) | |
| return result | |
| def faction_state(f: dict[str, Any]) -> dict[str, Any]: | |
| result = dict(f) | |
| result["members"] = list(f["members"]) | |
| result["relations"] = {str(k): v for k, v in f["relations"].items()} | |
| return result | |
| return { | |
| "version": 1, | |
| "seed": self.seed, "width": self.width, "height": self.height, | |
| "day": self.day, "paused": self.paused, "speed": self.speed, | |
| "created_at": self.created_at, | |
| "terrain": self.terrain, | |
| "npcs": {str(k): npc_state(v) for k, v in self.npcs.items()}, | |
| "monsters": {str(k): v for k, v in self.monsters.items()}, | |
| "places": {str(k): v for k, v in self.places.items()}, | |
| "factions": {str(k): faction_state(v) for k, v in self.factions.items()}, | |
| "logs": list(self.logs), "major_logs": list(self.major_logs), | |
| "archived_counts": dict(self.archived_counts), | |
| "response_teams": {str(k): v for k, v in self.response_teams.items()}, | |
| "operations": self.operations, "anomalies": self.anomalies, | |
| "artifacts": {str(k): v for k, v in self.artifacts.items()}, | |
| "next_npc_id": self.next_npc_id, "next_monster_id": self.next_monster_id, | |
| "next_place_id": self.next_place_id, "next_artifact_id": self.next_artifact_id, | |
| "rng_state": json_tuple(self.rng.getstate()), | |
| } | |
| def from_state(cls, data: dict[str, Any]) -> "World": | |
| world = cls(data["seed"], 0, data["width"], data["height"], generate=False) | |
| world.day = int(data["day"]) | |
| world.paused = bool(data.get("paused", True)) | |
| world.speed = float(data.get("speed", 4.0)) | |
| world.created_at = float(data.get("created_at", time.time())) | |
| world.terrain = data["terrain"] | |
| world.npcs = {} | |
| for key, n in data["npcs"].items(): | |
| n["id"] = int(n["id"]) | |
| n["memories"] = deque(n.get("memories", []), maxlen=180) | |
| n["followers"] = set(int(x) for x in n.get("followers", [])) | |
| world.npcs[int(key)] = n | |
| world.monsters = {int(k): v for k, v in data["monsters"].items()} | |
| world.places = {int(k): v for k, v in data["places"].items()} | |
| world.factions = {} | |
| for key, f in data["factions"].items(): | |
| f["members"] = set(int(x) for x in f.get("members", [])) | |
| f["relations"] = {int(k): v for k, v in f.get("relations", {}).items()} | |
| world.factions[int(key)] = f | |
| world.logs = deque(data.get("logs", []), maxlen=20_000) | |
| world.major_logs = deque(data.get("major_logs", []), maxlen=4_000) | |
| world.archived_counts = Counter(data.get("archived_counts", {})) | |
| world.response_teams = {int(k): v for k, v in data.get("response_teams", {}).items()} | |
| world.operations = list(data.get("operations", [])) | |
| world.anomalies = list(data.get("anomalies", [])) | |
| world.artifacts = {int(k): v for k, v in data.get("artifacts", {}).items()} | |
| world.next_npc_id = int(data.get("next_npc_id", max(world.npcs, default=0) + 1)) | |
| world.next_monster_id = int(data.get("next_monster_id", max(world.monsters, default=0) + 1)) | |
| world.next_place_id = int(data.get("next_place_id", max(world.places, default=0) + 1)) | |
| world.next_artifact_id = int(data.get("next_artifact_id", max(world.artifacts, default=0) + 1)) | |
| world.rng.setstate(tuple_tree(data["rng_state"])) | |
| world.npc_tiles = defaultdict(set) | |
| world.monster_tiles = defaultdict(set) | |
| world.place_tiles = defaultdict(list) | |
| for npc in world.npcs.values(): | |
| if npc["alive"]: | |
| world.npc_tiles[(npc["x"], npc["y"])].add(npc["id"]) | |
| for monster in world.monsters.values(): | |
| if monster["alive"]: | |
| world.monster_tiles[(monster["x"], monster["y"])].add(monster["id"]) | |
| for place in world.places.values(): | |
| world.place_tiles[(place["x"], place["y"])].append(place["id"]) | |
| world._build_terrain_svg() | |
| return world | |
| # -- rendering ---------------------------------------------------------- | |
| def render_metrics(self) -> str: | |
| living = sum(1 for n in self.npcs.values() if n["alive"]) | |
| dead = len(self.npcs) - living | |
| monsters = sum(1 for m in self.monsters.values() if m["alive"]) | |
| crises = sum(1 for p in self.places.values() if p["state"] not in {"stable", "unclaimed", "excavated"}) | |
| vessels = sum(1 for n in self.npcs.values() if n["alive"] and n["occult_stage"] >= 4) | |
| status = "Paused" if self.paused else f"Running at {self.speed:g} days/sec" | |
| return f""" | |
| <div class="metrics"> | |
| <div class="metric"><span>Day</span><b>{fmt_int(self.day)}</b><small>{safe(status)}</small></div> | |
| <div class="metric"><span>Living protagonists</span><b>{fmt_int(living)}</b><small>{fmt_int(dead)} dead</small></div> | |
| <div class="metric"><span>Active monsters</span><b>{fmt_int(monsters)}</b><small>{len(self.response_teams)} response teams</small></div> | |
| <div class="metric"><span>Places in crisis</span><b>{fmt_int(crises)}</b><small>{len(self.anomalies)} persistent anomalies</small></div> | |
| <div class="metric"><span>Vessel candidates</span><b>{fmt_int(vessels)}</b><small>{len(self.artifacts)} artifacts recovered</small></div> | |
| </div> | |
| """ | |
| def render_map(self) -> str: | |
| cell = 10 | |
| width_px = self.width * cell | |
| height_px = self.height * cell | |
| layers: list[str] = [self._terrain_svg] | |
| # Places | |
| place_colours = { | |
| "city": "#f0c674", "town": "#d8c28c", "fort": "#c0c8d8", | |
| "market": "#7bdff2", "ruin": "#c792ea", "shrine": "#ef8fff", | |
| "academy": "#80d6ff", "estate": "#c9b79c", "lair": "#ff6f7d", | |
| } | |
| for p in self.places.values(): | |
| x = p["x"] * cell + cell / 2 | |
| y = p["y"] * cell + cell / 2 | |
| colour = place_colours[p["type"]] | |
| opacity = 0.95 if p["state"] in {"stable", "unclaimed", "excavated"} else 1 | |
| size = 3.5 if p["type"] in {"city", "fort", "ruin", "shrine", "lair"} else 2.4 | |
| if p["state"] in {"besieged", "evacuating", "destroyed", "anomalous"}: | |
| layers.append(f'<circle cx="{x}" cy="{y}" r="{size+2}" fill="none" stroke="#ff6f7d" stroke-width="1.5" opacity=".9"/>') | |
| layers.append(f'<rect x="{x-size}" y="{y-size}" width="{size*2}" height="{size*2}" rx="1" fill="{colour}" opacity="{opacity}"><title>{safe(p["name"])} — {safe(p["type"])} — {safe(p["state"])}</title></rect>') | |
| # Population density, representing everyone without drawing 5,000 circles. | |
| max_density = max((len(ids) for ids in self.npc_tiles.values()), default=1) | |
| for (x, y), ids in self.npc_tiles.items(): | |
| if not ids: | |
| continue | |
| count = len(ids) | |
| r = 1.4 + 4.5 * math.sqrt(count / max_density) | |
| opacity = 0.22 + 0.45 * min(1, count / 12) | |
| layers.append(f'<circle cx="{x*cell+5}" cy="{y*cell+5}" r="{r:.1f}" fill="#80d6ff" opacity="{opacity:.2f}"><title>{count} protagonist NPCs</title></circle>') | |
| # Famous individuals | |
| notable = sorted([n for n in self.npcs.values() if n["alive"]], key=lambda n: n["fame"] + n["level"] * 4, reverse=True)[:80] | |
| for n in notable: | |
| x, y = n["x"] * cell + 5, n["y"] * cell + 5 | |
| layers.append(f'<circle cx="{x}" cy="{y}" r="2.3" fill="#ffffff" stroke="#111827" stroke-width=".8"><title>{safe(n["name"])} — level {n["level"]} — fame {n["fame"]}</title></circle>') | |
| # Monsters | |
| threats = sorted([m for m in self.monsters.values() if m["alive"]], key=lambda m: m["notoriety"] + m["level"] * 4, reverse=True)[:120] | |
| for m in threats: | |
| x, y = m["x"] * cell + 5, m["y"] * cell + 5 | |
| r = 2.5 + min(4, m["level"] / 4) | |
| layers.append(f'<path d="M {x} {y-r} L {x+r} {y} L {x} {y+r} L {x-r} {y} Z" fill="#ff6f7d" stroke="#2b0b10" stroke-width=".8"><title>{safe(m["name"])} — level {m["level"]} — notoriety {m["notoriety"]}</title></path>') | |
| return f""" | |
| <div class="map-shell"> | |
| <svg viewBox="0 0 {width_px} {height_px}" role="img" aria-label="Procedural world map with settlements, NPC density and monster threats">{''.join(layers)}</svg> | |
| <div class="legend"><span><i class="dot npc"></i> NPC density / notable NPC</span><span><i class="diamond"></i> monster</span><span><i class="square"></i> place</span><span><i class="ring"></i> crisis or anomaly</span></div> | |
| </div> | |
| """ | |
| def render_chronicle(self, minimum_importance: int = 2) -> str: | |
| selected = [e for e in reversed(self.logs) if e["importance"] >= minimum_importance][:150] | |
| if not selected: | |
| return "<div class='empty'>No events at this importance yet.</div>" | |
| parts = ["<div class='chronicle'>"] | |
| for e in selected: | |
| badge = safe(e["category"]) | |
| details = "" | |
| if e.get("thought") or e.get("alternatives"): | |
| alt = "".join(f"<li>{safe(name)} — score {score}</li>" for name, score in e.get("alternatives", [])) | |
| details = f"<details><summary>Private reasoning</summary><p>{safe(e.get('thought') or 'No private trace recorded.')}</p>{'<ul>'+alt+'</ul>' if alt else ''}</details>" | |
| parts.append( | |
| f"<article class='event importance-{e['importance']}'><header><b>Day {e['day']}</b><span>{badge}</span></header><p>{safe(e['text'])}</p>{details}</article>" | |
| ) | |
| parts.append("</div>") | |
| return "".join(parts) | |
| def render_npc(self, npc_id: int | str | None) -> str: | |
| try: | |
| nid = int(npc_id) if npc_id is not None else min(self.npcs) | |
| except (TypeError, ValueError): | |
| nid = min(self.npcs) | |
| npc = self.npcs.get(nid) or self.npcs[min(self.npcs)] | |
| place = self.place_at(npc["x"], npc["y"]) | |
| faction = self.factions.get(npc["faction"]) | |
| controller = self.npcs.get(npc["bound_to"]) if npc["bound_to"] else None | |
| plan_rows = [] | |
| for idx, step in enumerate(npc["plan"]): | |
| state = "done" if idx < npc["plan_index"] else "active" if idx == npc["plan_index"] else "future" | |
| progress = npc["plan_progress"] if state == "active" else 100 if state == "done" else 0 | |
| plan_rows.append(f"<li class='{state}'><span>{safe(step)}</span><b>{progress}%</b></li>") | |
| relationships = [] | |
| for key, rel in npc["relationships"].items(): | |
| other = self.npcs.get(int(key)) | |
| if other: | |
| salience = abs(rel["trust"]) + rel["fear"] + rel["attraction"] + rel["resentment"] + rel["dependency"] | |
| relationships.append((salience, other, rel)) | |
| relationships.sort(reverse=True, key=lambda item: item[0]) | |
| rel_rows = "".join( | |
| f"<tr><td>{safe(other['name'])}</td><td>{rel['trust']}</td><td>{rel['fear']}</td><td>{rel['attraction']}</td><td>{rel['resentment']}</td><td>{rel['dependency']}</td></tr>" | |
| for _, other, rel in relationships[:12] | |
| ) or "<tr><td colspan='6'>No highly salient relationships yet.</td></tr>" | |
| memories = "".join(f"<li><b>Day {m['day']}</b> — {safe(m['text'])}</li>" for m in list(npc["memories"])[-12:][::-1]) or "<li>No recorded memories.</li>" | |
| traits = "".join(f"<span class='trait'><b>{safe(k)}</b> {v}</span>" for k, v in sorted(npc["traits"].items())) | |
| status = "dead" if not npc["alive"] else "imprisoned" if npc["imprisoned_until"] > self.day else "bound" if controller else "active" | |
| return f""" | |
| <div class="npc-card"> | |
| <div class="npc-head"><div><h2>{safe(npc['name'])}</h2><p>{safe(npc['archetype'])} · age {npc['age']} · <b>{status}</b></p></div><div class="level">Lv {npc['level']}</div></div> | |
| <div class="summary-grid"> | |
| <div><span>Location</span><b>{safe(place['name'] if place else self.terrain[npc['y']][npc['x']])}</b></div> | |
| <div><span>Faction</span><b>{safe(faction['name'] if faction else 'Independent')}</b></div> | |
| <div><span>Health</span><b>{npc['hp']} / {npc['max_hp']}</b></div> | |
| <div><span>Wealth</span><b>{npc['wealth']} crowns</b></div> | |
| <div><span>Fame / infamy</span><b>{npc['fame']} / {npc['infamy']}</b></div> | |
| <div><span>Occult</span><b>lore {npc['lore']} · corruption {npc['corruption']}</b></div> | |
| </div> | |
| <h3>Life ambition</h3><p><b>{safe(npc['goal'])}</b></p> | |
| <ol class="plan">{''.join(plan_rows)}</ol> | |
| <p><b>Last action:</b> {safe(npc['last_action'])}</p> | |
| <p><b>Controller:</b> {safe(controller['name']) if controller else 'none'} · <b>Followers:</b> {len(npc['followers'])} · <b>Monster kills:</b> {npc['monster_kills']}</p> | |
| <p><b>Patron:</b> {safe(npc['patron']) if npc['patron'] else 'none'} · <b>Occult stage:</b> {npc['occult_stage']} · <b>compatibility:</b> {npc['vessel_compatibility']}%</p> | |
| <h3>Traits</h3><div class="traits">{traits}</div> | |
| <h3>Most salient relationships</h3> | |
| <div class="table-wrap"><table><thead><tr><th>Person</th><th>Trust</th><th>Fear</th><th>Attraction</th><th>Resentment</th><th>Dependency</th></tr></thead><tbody>{rel_rows}</tbody></table></div> | |
| <h3>Recent personal memories</h3><ul class="memories">{memories}</ul> | |
| </div> | |
| """ | |
| def render_factions(self) -> str: | |
| rows = [] | |
| ordered = sorted(self.factions.values(), key=lambda f: f["power"], reverse=True) | |
| for faction in ordered: | |
| leader = self.npcs.get(faction["leader"]) | |
| alive_members = sum(1 for nid in faction["members"] if nid in self.npcs and self.npcs[nid]["alive"]) | |
| rows.append(f""" | |
| <div class="faction-card"> | |
| <h3>{safe(faction['name'])}</h3> | |
| <p>{safe(faction['agenda'])}</p> | |
| <div class="bar"><i style="width:{clamp(faction['power']/2, 0, 100):.1f}%"></i></div> | |
| <small>Power {faction['power']} · treasury {faction['treasury']} · {alive_members} living members · leader {safe(leader['name']) if leader else 'vacant'} · {faction['victories']} victories / {faction['defeats']} defeats</small> | |
| </div> | |
| """) | |
| return "<div class='faction-grid'>" + "".join(rows) + "</div>" | |
| def render_threats(self) -> str: | |
| threats = sorted([m for m in self.monsters.values() if m["alive"]], key=lambda m: m["notoriety"] + m["level"] * 5, reverse=True)[:20] | |
| monster_rows = "".join( | |
| f"<tr><td>{safe(m['name'])}</td><td>{m['level']}</td><td>{m['hp']}/{m['max_hp']}</td><td>{m['notoriety']}</td><td>{m['kills']}</td><td>{safe(', '.join(m['adaptations']) or 'none')}</td></tr>" | |
| for m in threats | |
| ) | |
| crises = sorted( | |
| [p for p in self.places.values() if p["state"] not in {"stable", "unclaimed", "excavated"}], | |
| key=lambda p: p["damage"], reverse=True, | |
| )[:20] | |
| crisis_rows = "".join( | |
| f"<tr><td>{safe(p['name'])}</td><td>{safe(p['state'])}</td><td>{p['population']}</td><td>{p['damage']}</td><td>{p['security']}</td><td>{p['price_index']:.2f}</td></tr>" | |
| for p in crises | |
| ) or "<tr><td colspan='6'>No active settlement crises.</td></tr>" | |
| return f""" | |
| <h3>Major monsters</h3><div class="table-wrap"><table><thead><tr><th>Threat</th><th>Lv</th><th>HP</th><th>Notoriety</th><th>Kills</th><th>Adaptations</th></tr></thead><tbody>{monster_rows}</tbody></table></div> | |
| <h3>Settlement states</h3><div class="table-wrap"><table><thead><tr><th>Place</th><th>State</th><th>Population</th><th>Damage</th><th>Security</th><th>Price index</th></tr></thead><tbody>{crisis_rows}</tbody></table></div> | |
| """ | |
| def render_rankings(self) -> str: | |
| living = [n for n in self.npcs.values() if n["alive"]] | |
| legends = sorted(living, key=lambda n: n["fame"] + n["level"] * 5 + n["monster_kills"] * 10, reverse=True)[:15] | |
| rows = "".join( | |
| f"<tr><td>{idx}</td><td>{safe(n['name'])}</td><td>{n['level']}</td><td>{n['fame']}</td><td>{n['monster_kills']}</td><td>{safe(n['goal'])}</td></tr>" | |
| for idx, n in enumerate(legends, 1) | |
| ) | |
| return f"<div class='table-wrap'><table><thead><tr><th>#</th><th>Legend</th><th>Lv</th><th>Fame</th><th>Monster kills</th><th>Life ambition</th></tr></thead><tbody>{rows}</tbody></table></div>" | |
| def npc_choices(self) -> list[tuple[str, int]]: | |
| return [(f"{n['name']} — Lv {n['level']} — {'dead' if not n['alive'] else n['goal']}", n["id"]) for n in sorted(self.npcs.values(), key=lambda x: (not x["alive"], x["name"]))] | |
| # ----------------------------------------------------------------------------- | |
| # Shared server runtime | |
| # ----------------------------------------------------------------------------- | |
| WORLD_LOCK = threading.RLock() | |
| WORLD = World(seed=1729, npc_count=540) | |
| STOP_EVENT = threading.Event() | |
| def engine_loop() -> None: | |
| accumulator = 0.0 | |
| last = time.monotonic() | |
| while not STOP_EVENT.is_set(): | |
| time.sleep(0.04) | |
| now = time.monotonic() | |
| elapsed = now - last | |
| last = now | |
| with WORLD_LOCK: | |
| world = WORLD | |
| if world.paused: | |
| accumulator = 0.0 | |
| continue | |
| accumulator += elapsed * world.speed | |
| days = min(25, int(accumulator)) | |
| if days > 0: | |
| world.step(days) | |
| accumulator -= days | |
| ENGINE_THREAD = threading.Thread(target=engine_loop, name="world-engine", daemon=True) | |
| ENGINE_THREAD.start() | |
| def snapshot(selected_npc: int | str | None, importance: int = 2) -> tuple[str, str, str, str, str, str, str, str]: | |
| with WORLD_LOCK: | |
| world = WORLD | |
| status = f"Seed {world.seed} · day {world.day:,} · {'paused' if world.paused else f'{world.speed:g} days/sec'} · simulation executes on the Space CPU" | |
| return ( | |
| world.render_metrics(), | |
| world.render_map(), | |
| world.render_chronicle(int(importance)), | |
| world.render_npc(selected_npc), | |
| world.render_factions(), | |
| world.render_threats(), | |
| world.render_rankings(), | |
| status, | |
| ) | |
| def toggle_pause(selected_npc: int | str | None, importance: int) -> tuple[Any, ...]: | |
| with WORLD_LOCK: | |
| WORLD.paused = not WORLD.paused | |
| label = "▶ Resume" if WORLD.paused else "⏸ Pause" | |
| return (gr.Button(value=label),) + snapshot(selected_npc, importance) | |
| def set_speed(speed: float) -> str: | |
| with WORLD_LOCK: | |
| WORLD.speed = float(speed) | |
| return f"Simulation speed set to {float(speed):g} days per real second." | |
| def manual_step(days: int, selected_npc: int | str | None, importance: int) -> tuple[str, ...]: | |
| with WORLD_LOCK: | |
| WORLD.paused = True | |
| WORLD.step(days) | |
| return snapshot(selected_npc, importance) | |
| def create_world(seed_text: str, npc_count: int) -> tuple[Any, ...]: | |
| global WORLD | |
| try: | |
| seed = int(seed_text.strip()) if seed_text and seed_text.strip() else secrets.randbits(31) | |
| except ValueError: | |
| seed = secrets.randbits(31) | |
| count = int(clamp(int(npc_count), 50, 5_000)) | |
| new_world = World(seed=seed, npc_count=count) | |
| with WORLD_LOCK: | |
| WORLD = new_world | |
| choices = WORLD.npc_choices() | |
| selected = choices[0][1] | |
| return ( | |
| gr.Dropdown(choices=choices, value=selected), | |
| gr.Button(value="▶ Resume"), | |
| ) + snapshot(selected, 2) | |
| def export_save() -> str: | |
| with WORLD_LOCK: | |
| state = WORLD.to_state() | |
| day = WORLD.day | |
| seed = WORLD.seed | |
| path = Path(tempfile.gettempdir()) / f"protagonist-world-seed-{seed}-day-{day}.json.gz" | |
| with gzip.open(path, "wt", encoding="utf-8", compresslevel=6) as handle: | |
| json.dump(state, handle, ensure_ascii=False, separators=(",", ":")) | |
| return str(path) | |
| def import_save(file_path: str | None) -> tuple[Any, ...]: | |
| global WORLD | |
| if not file_path: | |
| raise gr.Error("Choose a simulator save first.") | |
| try: | |
| path = Path(file_path) | |
| if path.suffix == ".gz": | |
| with gzip.open(path, "rt", encoding="utf-8") as handle: | |
| data = json.load(handle) | |
| else: | |
| with path.open("r", encoding="utf-8") as handle: | |
| data = json.load(handle) | |
| new_world = World.from_state(data) | |
| except Exception as exc: | |
| raise gr.Error(f"Could not import this save: {exc}") from exc | |
| with WORLD_LOCK: | |
| WORLD = new_world | |
| WORLD.paused = True | |
| choices = WORLD.npc_choices() | |
| selected = choices[0][1] | |
| return ( | |
| gr.Dropdown(choices=choices, value=selected), | |
| gr.Button(value="▶ Resume"), | |
| ) + snapshot(selected, 2) | |
| def export_chronicle() -> str: | |
| with WORLD_LOCK: | |
| events = list(WORLD.logs) | |
| day = WORLD.day | |
| seed = WORLD.seed | |
| path = Path(tempfile.gettempdir()) / f"protagonist-world-chronicle-seed-{seed}-day-{day}.txt" | |
| with path.open("w", encoding="utf-8") as handle: | |
| handle.write(f"PROTAGONIST WORLD SIMULATOR — seed {seed} — exported day {day}\n\n") | |
| for event in events: | |
| handle.write(f"[Day {event['day']}] {event['text']}\n") | |
| if event.get("thought"): | |
| handle.write(f" Thought: {event['thought']}\n") | |
| if event.get("alternatives"): | |
| handle.write(" Alternatives: " + "; ".join(f"{name} ({score})" for name, score in event["alternatives"]) + "\n") | |
| return str(path) | |
| # ----------------------------------------------------------------------------- | |
| # Gradio interface | |
| # ----------------------------------------------------------------------------- | |
| CSS = """ | |
| :root { --panel:#111827; --panel2:#172033; --line:#334155; --text:#e5edf8; --muted:#94a3b8; --accent:#60c7f7; --danger:#fb7185; --gold:#f3c969; } | |
| .gradio-container { max-width: 1700px !important; } | |
| #title h1 { margin-bottom: .15rem; } | |
| #title p { color: var(--muted); margin-top: 0; } | |
| .metrics { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; margin:4px 0 12px; } | |
| .metric { border:1px solid var(--line); border-radius:12px; padding:12px; background:linear-gradient(180deg,#172033,#111827); } | |
| .metric span,.summary-grid span { display:block; color:var(--muted); font-size:.82rem; } | |
| .metric b { display:block; font-size:1.5rem; margin:.15rem 0; } | |
| .metric small { color:var(--muted); } | |
| .map-shell { border:1px solid var(--line); border-radius:12px; overflow:hidden; background:#09111c; } | |
| .map-shell svg { width:100%; height:auto; display:block; image-rendering:auto; } | |
| .legend { display:flex; flex-wrap:wrap; gap:14px; padding:8px 10px; color:var(--muted); font-size:.82rem; background:#101827; } | |
| .legend i { display:inline-block; vertical-align:middle; margin-right:4px; } | |
| .dot.npc { width:9px; height:9px; border-radius:999px; background:#80d6ff; } | |
| .diamond { width:9px; height:9px; background:#ff6f7d; transform:rotate(45deg); } | |
| .square { width:9px; height:9px; background:#f0c674; } | |
| .ring { width:10px; height:10px; border:2px solid #ff6f7d; border-radius:50%; } | |
| .chronicle { display:flex; flex-direction:column; gap:8px; max-height:760px; overflow:auto; padding-right:4px; } | |
| .event { border:1px solid var(--line); border-left:4px solid #64748b; border-radius:10px; padding:9px 11px; background:#111827; } | |
| .event.importance-3 { border-left-color:#60a5fa; }.event.importance-4 { border-left-color:#c084fc; }.event.importance-5 { border-left-color:#fb7185; } | |
| .event header { display:flex; justify-content:space-between; gap:10px; color:var(--muted); font-size:.82rem; } | |
| .event header span { text-transform:uppercase; letter-spacing:.08em; }.event p { margin:.45rem 0; } | |
| details { color:var(--muted); } details p { color:var(--muted); } | |
| .npc-card h2 { margin:0; }.npc-head { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; }.npc-head p { margin:.25rem 0; color:var(--muted); }.level { border:1px solid var(--line); border-radius:999px; padding:6px 10px; font-weight:700; } | |
| .summary-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:12px 0; }.summary-grid>div { border:1px solid var(--line); border-radius:9px; padding:8px; background:#111827; }.summary-grid b { font-size:.95rem; } | |
| .plan { display:flex; flex-direction:column; gap:5px; padding-left:1.4rem; }.plan li { padding:5px 7px; border-radius:7px; }.plan li b { float:right; }.plan .done { color:#7dd3a8; }.plan .active { background:#1d3a52; }.plan .future { color:var(--muted); } | |
| .traits { display:flex; flex-wrap:wrap; gap:6px; }.trait { border:1px solid var(--line); border-radius:999px; padding:4px 8px; color:var(--muted); }.trait b { color:var(--text); } | |
| .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; font-size:.9rem; } th,td { padding:7px 8px; border-bottom:1px solid var(--line); text-align:left; } th { color:var(--muted); position:sticky; top:0; background:#111827; } | |
| .memories { max-height:350px; overflow:auto; }.memories li { margin-bottom:7px; } | |
| .faction-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }.faction-card { border:1px solid var(--line); border-radius:11px; padding:10px; background:#111827; }.faction-card h3 { margin:0; }.faction-card p { color:var(--muted); min-height:2.6em; }.bar { height:10px; background:#273449; border-radius:999px; overflow:hidden; margin:8px 0; }.bar i { display:block; height:100%; background:#60c7f7; } | |
| .empty { color:var(--muted); padding:20px; text-align:center; } | |
| @media (max-width:900px) { .metrics { grid-template-columns:repeat(2,minmax(0,1fr)); }.summary-grid,.faction-grid { grid-template-columns:1fr; } } | |
| """ | |
| with gr.Blocks(title="Protagonist World Simulator") as demo: | |
| gr.Markdown( | |
| "# Protagonist World Simulator — Server Edition\n" | |
| "Every NPC is autonomous. The world runs on the **Hugging Face Space CPU**, with persistent plans, sparse relationships, finite ruins, real response teams, faction operations, unique occult thresholds, and exportable saves.", | |
| elem_id="title", | |
| ) | |
| with gr.Row(): | |
| pause_btn = gr.Button("▶ Resume", variant="primary") | |
| step1_btn = gr.Button("Step 1 day") | |
| step10_btn = gr.Button("Step 10 days") | |
| step100_btn = gr.Button("Step 100 days") | |
| speed = gr.Slider(0.5, 200, value=4, step=0.5, label="Days per real second", scale=2) | |
| importance = gr.Slider(1, 5, value=2, step=1, label="Chronicle minimum importance", scale=1) | |
| status = gr.Markdown("Simulation initialized and paused.") | |
| metrics = gr.HTML() | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=3, min_width=520): | |
| world_map = gr.HTML() | |
| rankings = gr.HTML(label="Living legends") | |
| with gr.Column(scale=2, min_width=380): | |
| with gr.Tabs(): | |
| with gr.Tab("World chronicle"): | |
| chronicle = gr.HTML() | |
| with gr.Tab("NPC mind"): | |
| npc_select = gr.Dropdown(choices=WORLD.npc_choices(), value=1, label="Inspect protagonist NPC", filterable=True) | |
| npc_detail = gr.HTML() | |
| with gr.Tab("Factions"): | |
| factions = gr.HTML() | |
| with gr.Tab("Threats & settlements"): | |
| threats = gr.HTML() | |
| with gr.Tab("Save / new world"): | |
| gr.Markdown( | |
| "### New shared world\n" | |
| "The population slider changes CPU and memory demand. Start with 540; optimized spatial indexes make several thousand possible on stronger CPU hardware." | |
| ) | |
| seed_input = gr.Textbox(label="Seed", value="1729") | |
| npc_count_input = gr.Slider(50, 5_000, value=540, step=10, label="Protagonist NPCs") | |
| new_world_btn = gr.Button("Create new world", variant="stop") | |
| gr.Markdown("### Export / import") | |
| with gr.Row(): | |
| export_btn = gr.Button("Export full save") | |
| export_log_btn = gr.Button("Export detailed chronicle") | |
| save_download = gr.File(label="Download", interactive=False) | |
| save_upload = gr.File(label="Import simulator save", type="filepath") | |
| import_btn = gr.Button("Import selected save") | |
| gr.Markdown( | |
| "**Space restarts:** the default disk is ephemeral. Download saves manually, or attach persistent storage and adapt `export_save()` to write there." | |
| ) | |
| timer = gr.Timer(value=1.0, active=True) | |
| SNAPSHOT_OUTPUTS = [metrics, world_map, chronicle, npc_detail, factions, threats, rankings, status] | |
| demo.load(snapshot, inputs=[npc_select, importance], outputs=SNAPSHOT_OUTPUTS, queue=False) | |
| timer.tick(snapshot, inputs=[npc_select, importance], outputs=SNAPSHOT_OUTPUTS, queue=False, show_progress="hidden") | |
| npc_select.change(snapshot, inputs=[npc_select, importance], outputs=SNAPSHOT_OUTPUTS, queue=False) | |
| importance.change(snapshot, inputs=[npc_select, importance], outputs=SNAPSHOT_OUTPUTS, queue=False) | |
| pause_btn.click(toggle_pause, inputs=[npc_select, importance], outputs=[pause_btn] + SNAPSHOT_OUTPUTS, queue=False) | |
| speed.change(set_speed, inputs=speed, outputs=status, queue=False) | |
| step1_btn.click(lambda selected, imp: manual_step(1, selected, imp), inputs=[npc_select, importance], outputs=SNAPSHOT_OUTPUTS) | |
| step10_btn.click(lambda selected, imp: manual_step(10, selected, imp), inputs=[npc_select, importance], outputs=SNAPSHOT_OUTPUTS) | |
| step100_btn.click(lambda selected, imp: manual_step(100, selected, imp), inputs=[npc_select, importance], outputs=SNAPSHOT_OUTPUTS) | |
| new_world_btn.click( | |
| create_world, | |
| inputs=[seed_input, npc_count_input], | |
| outputs=[npc_select, pause_btn] + SNAPSHOT_OUTPUTS, | |
| ) | |
| export_btn.click(export_save, outputs=save_download) | |
| export_log_btn.click(export_chronicle, outputs=save_download) | |
| import_btn.click( | |
| import_save, | |
| inputs=save_upload, | |
| outputs=[npc_select, pause_btn] + SNAPSHOT_OUTPUTS, | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(default_concurrency_limit=8).launch( | |
| server_name="0.0.0.0", | |
| server_port=int(os.environ.get("PORT", "7860")), | |
| show_error=True, | |
| css=CSS, | |
| ) | |