ai-simulation / app.py
adamm-hf's picture
adamm-hf HF Staff
Upload app.py
f4e5ee1 verified
Raw
History Blame Contribute Delete
112 kB
"""
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):,}"
@dataclass
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()),
}
@classmethod
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,
)