AK commited on
Commit
202a308
·
1 Parent(s): 68c6260

feat: world tick loop, dialogue, evolving relationships, event injection

Browse files
Files changed (1) hide show
  1. backend/world.py +219 -0
backend/world.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The world of Polis: locations, the population, and the tick loop.
3
+
4
+ A "tick" is one step of simulated time. On each tick every agent:
5
+ 1. perceives nearby agents and its location,
6
+ 2. retrieves relevant memories,
7
+ 3. decides an action (and possibly a line of dialogue),
8
+ 4. moves toward a chosen location,
9
+ 5. records what happened,
10
+ 6. occasionally reflects.
11
+
12
+ The engine emits a compact list of *events* per tick that the 3D frontend
13
+ replays: movements, speech bubbles, new memories, relationship changes, and
14
+ emergent "signals" (rumors spreading, gatherings forming).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ import random
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import List, Optional
23
+
24
+ from .agents import Agent
25
+ from .llm import llm
26
+
27
+ # ---- map ---------------------------------------------------------------------
28
+ LOCATIONS = {
29
+ "plaza": {"x": 0.0, "y": 0.0, "label": "Central Plaza"},
30
+ "market": {"x": 6.0, "y": 2.0, "label": "Market"},
31
+ "bakery": {"x": -5.0, "y": 3.0, "label": "Bakery"},
32
+ "clinic": {"x": 5.0, "y": -4.0, "label": "Clinic"},
33
+ "tavern": {"x": -4.0, "y": -5.0, "label": "Tavern"},
34
+ "garden": {"x": 2.0, "y": 6.0, "label": "Garden"},
35
+ "forge": {"x": -7.0, "y": -1.0, "label": "Forge"},
36
+ "harbor": {"x": 8.0, "y": -1.0, "label": "Harbor"},
37
+ }
38
+
39
+ PERSONAS = [
40
+ ("mara", "Mara", 34, "warm, curious, a natural connector", "baker", "bakery", "#f6c453"),
41
+ ("theo", "Theo", 41, "gruff but loyal, hates gossip", "blacksmith","forge", "#e6704b"),
42
+ ("ines", "Ines", 29, "sharp, ambitious, keeps score", "merchant", "market", "#6ec6ff"),
43
+ ("caleb", "Caleb", 52, "calm, wise, a good listener", "medic", "clinic", "#8bd17c"),
44
+ ("nadia", "Nadia", 26, "playful, dramatic, spreads rumors", "bard", "tavern", "#c792ea"),
45
+ ("otis", "Otis", 38, "anxious, dependable, over-plans", "harbormaster","harbor","#4dd0e1"),
46
+ ("lena", "Lena", 31, "idealistic, stubborn, community-minded","gardener","garden", "#a3e635"),
47
+ ("rurik", "Rurik", 47, "quiet, observant, secretly generous", "trader", "market", "#ff8a65"),
48
+ ]
49
+
50
+
51
+ @dataclass
52
+ class World:
53
+ seed: int = 42
54
+ tick: int = 0
55
+ agents: List[Agent] = field(default_factory=list)
56
+ log: List[dict] = field(default_factory=list) # rolling event feed
57
+ pending_events: List[str] = field(default_factory=list) # injected by user
58
+ rng: random.Random = field(default_factory=lambda: random.Random(42))
59
+
60
+ @classmethod
61
+ def bootstrap(cls, seed: int = 42) -> "World":
62
+ w = cls(seed=seed, rng=random.Random(seed))
63
+ for pid, name, age, traits, role, home, color in PERSONAS:
64
+ loc = LOCATIONS[home]
65
+ a = Agent(id=pid, name=name, age=age, traits=traits, role=role,
66
+ home=home, x=loc["x"], y=loc["y"], color=color,
67
+ location=home)
68
+ a.remember(0, f"My name is {name}. I work as a {role} and live near the {home}.",
69
+ kind="observation", importance=6.0)
70
+ a.goal = f"do good work as the {role} and stay close to people I trust"
71
+ w.agents.append(a)
72
+ # seed a couple of relationships so the graph isn't empty
73
+ w._link("mara", "nadia", 0.4)
74
+ w._link("theo", "caleb", 0.5)
75
+ w._link("ines", "rurik", -0.2)
76
+ return w
77
+
78
+ def _link(self, a: str, b: str, v: float) -> None:
79
+ da = self.get(a); db = self.get(b)
80
+ if da and db:
81
+ da.relationships[b] = round(v, 2)
82
+ db.relationships[a] = round(v, 2)
83
+
84
+ def get(self, agent_id: str) -> Optional[Agent]:
85
+ return next((a for a in self.agents if a.id == agent_id), None)
86
+
87
+ # -- external events ------------------------------------------------------
88
+ def inject_event(self, text: str) -> None:
89
+ self.pending_events.append(text.strip())
90
+
91
+ # -- one step of time -----------------------------------------------------
92
+ def step(self) -> dict:
93
+ self.tick += 1
94
+ events: List[dict] = []
95
+
96
+ # broadcast any injected world events as shared observations
97
+ for ev in self.pending_events:
98
+ for a in self.agents:
99
+ a.remember(self.tick, f"Something happened in town: {ev}",
100
+ kind="observation", importance=8.0)
101
+ events.append({"type": "world_event", "text": ev})
102
+ self.pending_events.clear()
103
+
104
+ # find conversation pairs (agents that are close together)
105
+ pairs = self._nearby_pairs(radius=2.5)
106
+ talked = set()
107
+
108
+ for a in self.agents:
109
+ partner = next((b for (x, b) in [(p[0], p[1]) for p in pairs] if x is a), None)
110
+ # decide + act
111
+ observation = self._perceive(a)
112
+ mems = a.retrieve(observation, self.tick, k=4)
113
+ mem_ctx = "; ".join(m.text for m in mems) or "nothing notable"
114
+
115
+ if partner and (a.id, partner.id) not in talked and (partner.id, a.id) not in talked:
116
+ line = self._dialogue(a, partner, mem_ctx)
117
+ a.remember(self.tick, f"I said to {partner.name}: {line}",
118
+ kind="dialogue", importance=5.0)
119
+ partner.remember(self.tick, f"{a.name} said to me: {line}",
120
+ kind="dialogue", importance=5.0)
121
+ self._adjust_relationship(a, partner, line)
122
+ talked.add((a.id, partner.id))
123
+ events.append({
124
+ "type": "dialogue", "agent": a.id, "to": partner.id,
125
+ "text": line, "x": a.x, "y": a.y,
126
+ })
127
+ else:
128
+ action, target_loc = self._decide(a, mem_ctx)
129
+ a.remember(self.tick, f"I {action}.", kind="observation")
130
+ self._move_toward(a, target_loc)
131
+ events.append({
132
+ "type": "action", "agent": a.id, "text": action,
133
+ "location": a.location, "x": round(a.x, 2), "y": round(a.y, 2),
134
+ })
135
+
136
+ # reflection
137
+ refl = a.maybe_reflect(self.tick)
138
+ if refl:
139
+ events.append({"type": "reflection", "agent": a.id,
140
+ "text": refl.text})
141
+
142
+ snapshot = {
143
+ "tick": self.tick,
144
+ "events": events,
145
+ "agents": [a.snapshot() for a in self.agents],
146
+ "budget": llm.ledger.as_dict(),
147
+ "live": llm.live,
148
+ }
149
+ self.log.append(snapshot)
150
+ if len(self.log) > 200:
151
+ self.log = self.log[-200:]
152
+ return snapshot
153
+
154
+ # -- helpers --------------------------------------------------------------
155
+ def _perceive(self, a: Agent) -> str:
156
+ others = [b.name for b in self.agents
157
+ if b is not a and self._dist(a, b) < 3.0]
158
+ who = f" I see {', '.join(others)} nearby." if others else " No one is close by."
159
+ return f"I am at the {a.location}.{who} My goal: {a.goal}."
160
+
161
+ def _decide(self, a: Agent, mem_ctx: str) -> tuple[str, str]:
162
+ locs = ", ".join(LOCATIONS.keys())
163
+ out = llm.chat(
164
+ system=(f"You are {a.identity()} Decide your next small action in one short "
165
+ f"sentence, then name ONE place to go from [{locs}]. "
166
+ f"Format exactly: ACTION | PLACE"),
167
+ user=f"Relevant memories: {mem_ctx}\nWhat do you do next?",
168
+ temperature=0.9, max_tokens=50,
169
+ )
170
+ action, _, place = out.partition("|")
171
+ place = place.strip().lower()
172
+ place = place if place in LOCATIONS else self.rng.choice(list(LOCATIONS))
173
+ action = action.strip().rstrip(".") or "wanders for a while"
174
+ return action, place
175
+
176
+ def _dialogue(self, a: Agent, b: Agent, mem_ctx: str) -> str:
177
+ sentiment = a.relationships.get(b.id, 0.0)
178
+ tone = "warmly" if sentiment > 0.2 else ("coldly" if sentiment < -0.2 else "neutrally")
179
+ out = llm.chat(
180
+ system=(f"You are {a.identity()} You are speaking {tone} to {b.name} "
181
+ f"({b.role}). Say ONE natural line of dialogue in quotes."),
182
+ user=f"Your relevant memories: {mem_ctx}\nWhat do you say to {b.name}?",
183
+ temperature=0.9, max_tokens=50,
184
+ )
185
+ return out.strip().strip('"')
186
+
187
+ def _adjust_relationship(self, a: Agent, b: Agent, line: str) -> None:
188
+ # cheap sentiment: warm words nudge up, harsh words nudge down
189
+ warm = any(w in line.lower() for w in
190
+ ["thank", "friend", "help", "glad", "love", "welcome", "together"])
191
+ cold = any(w in line.lower() for w in
192
+ ["no", "won't", "angry", "leave", "wrong", "never", "stop"])
193
+ delta = 0.08 if warm else (-0.08 if cold else 0.02)
194
+ for x, y in ((a, b), (b, a)):
195
+ cur = x.relationships.get(y.id, 0.0)
196
+ x.relationships[y.id] = round(max(-1.0, min(1.0, cur + delta)), 2)
197
+
198
+ def _move_toward(self, a: Agent, place: str) -> None:
199
+ loc = LOCATIONS[place]
200
+ a.location = place
201
+ # ease 60% of the way so movement looks gradual across ticks
202
+ a.x += (loc["x"] - a.x) * 0.6 + self.rng.uniform(-0.4, 0.4)
203
+ a.y += (loc["y"] - a.y) * 0.6 + self.rng.uniform(-0.4, 0.4)
204
+
205
+ def _nearby_pairs(self, radius: float) -> List[tuple]:
206
+ pairs = []
207
+ seen = set()
208
+ for a in self.agents:
209
+ for b in self.agents:
210
+ if a is b or (b.id, a.id) in seen:
211
+ continue
212
+ if self._dist(a, b) < radius:
213
+ pairs.append((a, b))
214
+ seen.add((a.id, b.id))
215
+ return pairs
216
+
217
+ @staticmethod
218
+ def _dist(a: Agent, b: Agent) -> float:
219
+ return math.hypot(a.x - b.x, a.y - b.y)