diff --git a/simulation/character_memory.py b/simulation/character_memory.py new file mode 100644 index 0000000..af1c44d --- /dev/null +++ b/simulation/character_memory.py @@ -0,0 +1,179 @@ +""" +Character memory system for a simulation. + +Each of the 5 characters maintains a personal memory bank. The `memory_call` +function retrieves contextually relevant memories for a given character based +on keyword matching and recency, simulating how a character "remembers" things +during a scene. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Memory: + content: str + tags: list[str] + importance: int # 1 (trivial) – 10 (critical) + timestamp: float = field(default_factory=time.time) + recalled_count: int = 0 + + def relevance_score(self, context_tags: list[str]) -> float: + tag_overlap = len(set(self.tags) & set(context_tags)) + recency_bonus = 1.0 / (1.0 + (time.time() - self.timestamp) / 3600) + return tag_overlap * self.importance + recency_bonus + + +@dataclass +class Character: + name: str + role: str + backstory: str + memories: list[Memory] = field(default_factory=list) + + def remember(self, content: str, tags: list[str], importance: int = 5) -> None: + """Store a new memory.""" + self.memories.append(Memory(content=content, tags=tags, importance=importance)) + + def __repr__(self) -> str: + return f"" + + +def memory_call( + character: Character, + context: str, + top_k: int = 3, + min_importance: int = 1, +) -> list[Memory]: + """ + Retrieve the most relevant memories for *character* given a natural-language + *context* string. + + Parameters + ---------- + character : The character whose memory bank is searched. + context : A phrase or sentence describing the current scene/situation. + top_k : Maximum number of memories to return. + min_importance: Only consider memories at or above this importance level. + + Returns + ------- + A list of Memory objects sorted by relevance (highest first). + """ + context_tags = [word.lower().strip(".,!?") for word in context.split()] + + candidates = [m for m in character.memories if m.importance >= min_importance] + ranked = sorted(candidates, key=lambda m: m.relevance_score(context_tags), reverse=True) + + results = ranked[:top_k] + for m in results: + m.recalled_count += 1 + return results + + +# --------------------------------------------------------------------------- +# 5 pre-built characters with seed memories +# --------------------------------------------------------------------------- + +def _build_characters() -> dict[str, Character]: + characters: dict[str, Character] = {} + + # 1 — Aria (Scout / Ranger) + aria = Character( + name="Aria", + role="Scout", + backstory="A sharp-eyed ranger who grew up on the forest edge and has survived three wars.", + ) + aria.remember("Spotted enemy scouts near the northern ridge at dusk.", ["enemy", "scout", "north", "ridge", "dusk"], importance=8) + aria.remember("Found a hidden passage behind the waterfall leading to the old keep.", ["passage", "waterfall", "keep", "hidden"], importance=9) + aria.remember("Lost her partner Kaen to a trap near the iron gate.", ["loss", "trap", "gate", "kaen"], importance=10) + aria.remember("The forest at night becomes impassable without a fire-stone.", ["forest", "night", "fire", "stone"], importance=6) + aria.remember("There is a safe camp two hours east of the broken bridge.", ["camp", "east", "bridge", "safe"], importance=7) + characters["aria"] = aria + + # 2 — Dex (Engineer / Inventor) + dex = Character( + name="Dex", + role="Engineer", + backstory="A tinkerer from the capital who specialises in mechanical traps and explosive devices.", + ) + dex.remember("The city's power grid runs through sublevel 3 — a single overload would black it out.", ["power", "grid", "sublevel", "city", "overload"], importance=9) + dex.remember("Prototype K-7 destabilises when exposed to high humidity.", ["prototype", "humidity", "unstable", "k7"], importance=8) + dex.remember("Traded the schematics for the vault door in exchange for safe passage.", ["schematics", "vault", "door", "trade"], importance=7) + dex.remember("The foreman owes him a favour after the Morrow bridge incident.", ["foreman", "favour", "bridge", "morrow"], importance=5) + dex.remember("Found an uncharted tunnel beneath the old foundry.", ["tunnel", "foundry", "underground", "hidden"], importance=8) + characters["dex"] = dex + + # 3 — Lyra (Healer / Alchemist) + lyra = Character( + name="Lyra", + role="Healer", + backstory="A travelling alchemist who has studied ancient texts on medicinal herbs and poisons.", + ) + lyra.remember("Moon-petal extract neutralises iron-rot poison within one hour.", ["poison", "iron", "rot", "cure", "moon", "petal"], importance=9) + lyra.remember("The plague that hit Silverfen in 412 was engineered, not natural.", ["plague", "silverfen", "engineered", "disease"], importance=10) + lyra.remember("Stored emergency antidotes beneath the healer's tent — third crate from the left.", ["antidote", "tent", "crate", "emergency"], importance=8) + lyra.remember("Treated the general's wound two seasons ago; he trusts her implicitly.", ["general", "trust", "wound", "ally"], importance=6) + lyra.remember("Nightshade tea induces vivid visions when brewed under starlight.", ["nightshade", "tea", "vision", "starlight"], importance=5) + characters["lyra"] = lyra + + # 4 — Marcus (Commander / Veteran) + marcus = Character( + name="Marcus", + role="Commander", + backstory="A seasoned military commander who has led armies for twenty years and never retreated.", + ) + marcus.remember("The enemy commander, General Voss, always overextends his left flank.", ["enemy", "general", "voss", "flank", "tactics"], importance=9) + marcus.remember("Lost the siege at Ironhold because of a supply route ambush.", ["siege", "ironhold", "supply", "ambush", "defeat"], importance=10) + marcus.remember("Soldiers morale breaks when they go three days without hot food.", ["morale", "soldiers", "food", "supply"], importance=7) + marcus.remember("Signed a quiet alliance with the river lords — they will send archers if asked.", ["alliance", "river", "archers", "reinforcement"], importance=8) + marcus.remember("The old barracks east of the city has a hidden armory stocked for 300 men.", ["barracks", "east", "armory", "weapons", "hidden"], importance=9) + characters["marcus"] = marcus + + # 5 — Sable (Spy / Infiltrator) + sable = Character( + name="Sable", + role="Spy", + backstory="A ghost operative who has infiltrated courts, guilds, and criminal syndicates for a decade.", + ) + sable.remember("The guild master uses a rotating cipher key — changes every new moon.", ["guild", "cipher", "key", "code", "moon"], importance=9) + sable.remember("Lord Cayne is secretly funding the rebel faction in the east.", ["cayne", "rebel", "east", "funding", "secret"], importance=10) + sable.remember("The palace kitchen staff change shifts at midnight — that's the blind spot.", ["palace", "kitchen", "midnight", "shift", "blind"], importance=8) + sable.remember("Used the alias 'Mira Vane' in the northern ports; cover is still intact.", ["alias", "mira", "vane", "north", "cover"], importance=7) + sable.remember("The informant in the docks will only meet at the second bell after low tide.", ["informant", "docks", "tide", "meeting"], importance=8) + characters["sable"] = sable + + return characters + + +CHARACTERS = _build_characters() + + +# --------------------------------------------------------------------------- +# Demo +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + scenarios = [ + ("aria", "We need to find a hidden passage to reach the keep quickly."), + ("dex", "The city power might be our way in — what do you know about the grid?"), + ("lyra", "Someone has been poisoned — do we have any cure or antidote?"), + ("marcus", "We are running low on supplies and morale is dropping fast."), + ("sable", "We need to infiltrate the palace tonight — what's the best window?"), + ] + + for char_key, context in scenarios: + character = CHARACTERS[char_key] + print(f"\n{'='*60}") + print(f"[{character.name} | {character.role}] context: \"{context}\"") + print(f"{'='*60}") + recalled = memory_call(character, context, top_k=2) + if recalled: + for i, mem in enumerate(recalled, 1): + print(f" [{i}] (importance={mem.importance}) {mem.content}") + else: + print(" (no relevant memories found)")