kikikita commited on
Commit
ef618e5
·
1 Parent(s): f9719a5

Enhance survival system with economic mechanics and role-specific directives

Browse files

- Introduced a coin economy where NPCs earn coins for productive actions (gathering, building, slaying beasts).
- Builders can now repair damaged homes, prioritizing base defense over new constructions.
- Newborn NPCs inherit their parents' nation and model connector, ensuring balanced role distribution.
- Updated survival prompts to reflect new survival priorities and rules based on NPC roles (ruler, guard, builder, gatherer).
- Added regression tests to validate the new economic and lifecycle behaviors.

scripts/selfplay_sim.py CHANGED
@@ -132,6 +132,38 @@ def _parse_briefing(content: str) -> dict[str, object]:
132
  facts["allies"] = _bullets_section(content, "ALLIES NEAR YOU")
133
  facts["resources"] = _bullets_section(content, "RESOURCES NEAR YOU")
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  m = re.search(r"ACTIONS YOU CAN TAKE: (.+?)\.", content)
136
  facts["allowed"] = (
137
  {a.strip() for a in m.group(1).split(",")} if m else {"move", "speak"}
@@ -164,8 +196,40 @@ def _bullets_section(content: str, header: str) -> list[dict[str, object]]:
164
  return items
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  def make_fake_completer():
168
- """Return a thread-safe completer that decides one action from the briefing."""
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  def complete(request: dict[str, object]) -> dict[str, object]:
171
  messages = request.get("messages") or []
@@ -175,16 +239,20 @@ def make_fake_completer():
175
  f = _parse_briefing(user["content"])
176
 
177
  npc_id = str(f["npc_id"])
 
178
  tick = int(f["tick"])
179
  allowed = set(f["allowed"]) # type: ignore[arg-type]
180
  inv = dict(f["inventory"]) # type: ignore[arg-type]
181
  threats = list(f["threats"]) # type: ignore[arg-type]
182
  allies = list(f["allies"]) # type: ignore[arg-type]
183
  resources = list(f["resources"]) # type: ignore[arg-type]
 
 
 
184
  seed = (hash(npc_id) ^ (tick * 2654435761)) & 0xFFFFFFFF
185
 
186
  # Rarely emit an unusable output so the fallback path is visibly logged.
187
- if seed % 41 == 0:
188
  return _unusable_response()
189
 
190
  # --- elections: vote when the window is open and we have not voted ---
@@ -192,26 +260,14 @@ def make_fake_completer():
192
  candidates = list(f["candidates"]) # type: ignore[arg-type]
193
  return _tool_response("vote", {"candidate_id": candidates[seed % len(candidates)]})
194
 
195
- # --- ruler drives the cannon programme ---
196
- if f["is_ruler"]:
197
- if "cannon_id" not in f and inv.get("coins", 0) >= 0:
198
- # craft only makes sense from the treasury; the ruler tries it.
199
- return _tool_response("use", {"use_type": "craft", "params": {"recipe_id": "cannon"}})
200
- if "cannon_id" in f and f.get("cannon_operator") is None:
201
  return _tool_response(
202
- "use",
203
- {"use_type": "assign_cannon_operator", "params": {"npc_id": npc_id}},
204
- )
205
- if "cannon_id" in f and f.get("cannon_operator") == npc_id and "enemy_treasury_pos" in f:
206
- ex, ez = f["enemy_treasury_pos"] # type: ignore[misc]
207
- return _tool_response(
208
- "use",
209
- {
210
- "use_type": "fire",
211
- "target_entity_id": f["cannon_id"],
212
- "params": {"x": ex, "z": ez},
213
- },
214
  )
 
215
 
216
  # --- survival upkeep ---
217
  if int(f["hunger"]) >= 45 and inv.get("food", 0) > 0:
@@ -219,49 +275,108 @@ def make_fake_completer():
219
  if int(f["health"]) < 55 and inv.get("herbs", 0) > 0:
220
  return _tool_response("use", {"use_type": "heal", "resource_type": "herbs"})
221
 
222
- # --- guards engage live threats ---
223
- if threats and f["role"] == "guard":
224
- return _tool_response("attack", {"target_entity_id": threats[0]["id"]})
225
-
226
- own_x, own_z = f["pos"] # type: ignore[misc]
227
-
228
- # --- raid the rival treasury for coins ---
229
- if "enemy_treasury_pos" in f and int(f["enemy_coins"]) > 0:
230
- ex, ez = f["enemy_treasury_pos"] # type: ignore[misc]
231
- dist = abs(ex - own_x) + abs(ez - own_z)
232
- if dist <= 6 and seed % 3 == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  return _tool_response(
234
  "transfer",
235
- {
236
- "target_id": f["enemy_treasury_id"],
237
- "resource_type": "coins",
238
- "amount": 2,
239
- "take": True,
240
- },
241
  )
242
- if seed % 13 == 0:
243
- # March toward the rival treasury to attempt a raid later.
244
- return _tool_response("move", {"x": ex, "z": ez})
 
 
 
 
 
 
 
 
 
 
 
245
 
246
- # --- periodically deposit surplus into the home treasury ---
247
  if "home_treasury_id" in f and seed % 6 == 0:
248
  for res in ("wood", "food", "herbs"):
249
- if inv.get(res, 0) > 0:
250
  return _tool_response(
251
  "transfer",
252
  {"target_id": f["home_treasury_id"], "resource_type": res, "amount": 1},
253
  )
254
 
255
- # --- occasionally chat with a nearby ally ---
256
- if allies and seed % 9 == 0:
257
- partner = allies[0]
258
- return _tool_response(
259
- "speak",
260
- {"message": "Let us raid the enemy treasury together!",
261
- "target_id": partner["id"]},
262
- )
263
-
264
- # --- default: gather the nearest reachable resource, else wander ---
 
 
 
 
 
 
 
265
  if resources:
266
  return _tool_response("use", {"use_type": "gather", "resource_id": resources[0]["id"]})
267
 
 
132
  facts["allies"] = _bullets_section(content, "ALLIES NEAR YOU")
133
  facts["resources"] = _bullets_section(content, "RESOURCES NEAR YOU")
134
 
135
+ # Self-preservation override line printed only when hurt with a threat on you.
136
+ facts["in_danger"] = "*** DANGER" in content
137
+
138
+ # BASE STATUS: which homes need a builder, and where home is (to rest/breed).
139
+ repair_targets: list[str] = []
140
+ rebuild_targets: list[str] = []
141
+ in_base = False
142
+ for line in content.splitlines():
143
+ if line.startswith("YOUR BASE"):
144
+ in_base = True
145
+ continue
146
+ if in_base:
147
+ if line.strip().startswith("- "):
148
+ mid = re.search(r'"(\S+?)"', line)
149
+ mpos = re.search(
150
+ r'"(\S+?)" at X=(-?\d+(?:\.\d+)?) Z=(-?\d+(?:\.\d+)?)', line
151
+ )
152
+ if mid and "DAMAGED" in line:
153
+ repair_targets.append(mid.group(1))
154
+ elif mid and "DESTROYED" in line:
155
+ rebuild_targets.append(mid.group(1))
156
+ if (
157
+ mpos
158
+ and ("your home" in line or "safe" in line)
159
+ and "home_house_pos" not in facts
160
+ ):
161
+ facts["home_house_pos"] = (float(mpos.group(2)), float(mpos.group(3)))
162
+ elif line.strip() == "" or not line.startswith(" "):
163
+ in_base = False
164
+ facts["repair_targets"] = repair_targets
165
+ facts["rebuild_targets"] = rebuild_targets
166
+
167
  m = re.search(r"ACTIONS YOU CAN TAKE: (.+?)\.", content)
168
  facts["allowed"] = (
169
  {a.strip() for a in m.group(1).split(",")} if m else {"move", "speak"}
 
196
  return items
197
 
198
 
199
+ # Varied speech pools so NPCs sound alive instead of chanting one war cry. The
200
+ # old single hard-coded line was exactly the "zombie raider spam" we are fixing.
201
+ _RULER_ORDERS = [
202
+ "{ally}, gather food and bank your coins in our treasury.",
203
+ "Builders, repair our homes before the beast comes back.",
204
+ "Guards, hold the line and keep the families safe.",
205
+ "Bring your wages home, {ally} - our nation must grow rich.",
206
+ "Stay near the houses tonight; we raise the next generation.",
207
+ "{ally}, watch the eastern approach and report what you see.",
208
+ ]
209
+ _CITIZEN_CHATTER = [
210
+ "Hauling my coins to the treasury, {ally}.",
211
+ "Careful, {ally} - a beast was prowling nearby.",
212
+ "I'll gather what I can for us today.",
213
+ "Our home could use repairs soon.",
214
+ "Stay safe out there, {ally}.",
215
+ "Good harvest today - the treasury grows.",
216
+ ]
217
+
218
+
219
  def make_fake_completer():
220
+ """Return a thread-safe completer that decides one action from the briefing.
221
+
222
+ This stands in for a well-prompted model: it follows the role mandates in the
223
+ new briefing (rulers govern, builders repair, guards protect, everyone banks
224
+ coins and flees lethal danger) so the offline ledger shows a living society.
225
+ """
226
+
227
+ def _say(pool: list[str], seed: int, ally_id: str | None, target: str | None):
228
+ line = pool[seed % len(pool)].format(ally=ally_id or "friend")
229
+ args: dict[str, object] = {"message": line, "intent": "social"}
230
+ if target is not None:
231
+ args["target_id"] = target
232
+ return _tool_response("speak", args)
233
 
234
  def complete(request: dict[str, object]) -> dict[str, object]:
235
  messages = request.get("messages") or []
 
239
  f = _parse_briefing(user["content"])
240
 
241
  npc_id = str(f["npc_id"])
242
+ role = str(f["role"])
243
  tick = int(f["tick"])
244
  allowed = set(f["allowed"]) # type: ignore[arg-type]
245
  inv = dict(f["inventory"]) # type: ignore[arg-type]
246
  threats = list(f["threats"]) # type: ignore[arg-type]
247
  allies = list(f["allies"]) # type: ignore[arg-type]
248
  resources = list(f["resources"]) # type: ignore[arg-type]
249
+ coins = int(inv.get("coins", 0))
250
+ ally_id = allies[0]["id"] if allies else None
251
+ own_x, own_z = f["pos"] # type: ignore[misc]
252
  seed = (hash(npc_id) ^ (tick * 2654435761)) & 0xFFFFFFFF
253
 
254
  # Rarely emit an unusable output so the fallback path is visibly logged.
255
+ if seed % 47 == 0:
256
  return _unusable_response()
257
 
258
  # --- elections: vote when the window is open and we have not voted ---
 
260
  candidates = list(f["candidates"]) # type: ignore[arg-type]
261
  return _tool_response("vote", {"candidate_id": candidates[seed % len(candidates)]})
262
 
263
+ # --- SELF-PRESERVATION first: hurt + a threat on you -> flee or shout ---
264
+ if f["in_danger"]:
265
+ if seed % 2 == 0:
 
 
 
266
  return _tool_response(
267
+ "speak",
268
+ {"message": "Help! A beast is on me!", "intent": "help_request"},
 
 
 
 
 
 
 
 
 
 
269
  )
270
+ return _tool_response("move", {"away": True})
271
 
272
  # --- survival upkeep ---
273
  if int(f["hunger"]) >= 45 and inv.get("food", 0) > 0:
 
275
  if int(f["health"]) < 55 and inv.get("herbs", 0) > 0:
276
  return _tool_response("use", {"use_type": "heal", "resource_type": "herbs"})
277
 
278
+ # --- RULER: govern; never personally raid ---
279
+ if f["is_ruler"]:
280
+ if "cannon_id" not in f and inv.get("coins", 0) >= 0:
281
+ return _tool_response("use", {"use_type": "craft", "params": {"recipe_id": "cannon"}})
282
+ if "cannon_id" in f and f.get("cannon_operator") is None and ally_id:
283
+ return _tool_response(
284
+ "use",
285
+ {"use_type": "assign_cannon_operator", "params": {"npc_id": ally_id}},
286
+ )
287
+ if coins > 0 and "home_treasury_id" in f:
288
+ return _tool_response(
289
+ "transfer",
290
+ {"target_id": f["home_treasury_id"], "resource_type": "coins", "amount": coins},
291
+ )
292
+ # Command the nation: address citizens by name with varied orders.
293
+ return _say(_RULER_ORDERS, seed, ally_id, ally_id)
294
+
295
+ # --- GUARD: protect allies and homes; raid only when home is safe ---
296
+ if role == "guard":
297
+ if threats:
298
+ return _tool_response("attack", {"target_entity_id": threats[0]["id"]})
299
+ if coins >= 3 and "home_treasury_id" in f:
300
+ return _tool_response(
301
+ "transfer",
302
+ {"target_id": f["home_treasury_id"], "resource_type": "coins", "amount": coins},
303
+ )
304
+ if (
305
+ "enemy_treasury_pos" in f
306
+ and int(f["enemy_coins"]) > 0
307
+ and not f["repair_targets"]
308
+ and not f["rebuild_targets"]
309
+ ):
310
+ ex, ez = f["enemy_treasury_pos"] # type: ignore[misc]
311
+ if abs(ex - own_x) + abs(ez - own_z) <= 6 and seed % 3 == 0:
312
+ return _tool_response(
313
+ "transfer",
314
+ {
315
+ "target_id": f["enemy_treasury_id"],
316
+ "resource_type": "coins",
317
+ "amount": 2,
318
+ "take": True,
319
+ },
320
+ )
321
+ if seed % 11 == 0:
322
+ return _tool_response("move", {"x": ex, "z": ez})
323
+ # Otherwise stay home and stand guard / earn.
324
+ if resources:
325
+ return _tool_response("use", {"use_type": "gather", "resource_id": resources[0]["id"]})
326
+ return _tool_response("move", {"x": own_x, "z": own_z})
327
+
328
+ # --- BUILDER: repair/raise homes before anything else ---
329
+ if role == "builder":
330
+ if f["repair_targets"]:
331
+ return _tool_response("use", {"use_type": "repair"})
332
+ if f["rebuild_targets"]:
333
+ return _tool_response("use", {"use_type": "build"})
334
+ if coins >= 3 and "home_treasury_id" in f:
335
  return _tool_response(
336
  "transfer",
337
+ {"target_id": f["home_treasury_id"], "resource_type": "coins", "amount": coins},
 
 
 
 
 
338
  )
339
+ wood_nodes = [r for r in resources if "wood" in str(r.get("id", ""))]
340
+ if wood_nodes:
341
+ return _tool_response("use", {"use_type": "gather", "resource_id": wood_nodes[0]["id"]})
342
+ if inv.get("wood", 0) >= 5:
343
+ return _tool_response("use", {"use_type": "build"})
344
+ if resources:
345
+ return _tool_response("use", {"use_type": "gather", "resource_id": resources[0]["id"]})
346
+
347
+ # --- bank wages: everyone carries coins home to enrich the nation ---
348
+ if coins >= 2 and "home_treasury_id" in f:
349
+ return _tool_response(
350
+ "transfer",
351
+ {"target_id": f["home_treasury_id"], "resource_type": "coins", "amount": coins},
352
+ )
353
 
354
+ # --- deposit other surplus occasionally ---
355
  if "home_treasury_id" in f and seed % 6 == 0:
356
  for res in ("wood", "food", "herbs"):
357
+ if inv.get(res, 0) > 1:
358
  return _tool_response(
359
  "transfer",
360
  {"target_id": f["home_treasury_id"], "resource_type": res, "amount": 1},
361
  )
362
 
363
+ # --- when safe, fed and healthy, rest at home to raise a family ---
364
+ if (
365
+ int(f["health"]) >= 85
366
+ and int(f["hunger"]) <= 35
367
+ and not threats
368
+ and coins == 0
369
+ and "home_house_pos" in f
370
+ and seed % 2 == 0
371
+ ):
372
+ hx, hz = f["home_house_pos"] # type: ignore[misc]
373
+ return _tool_response("move", {"x": hx, "z": hz})
374
+
375
+ # --- gather to earn coins, with the occasional friendly word ---
376
+ if resources and seed % 7 != 0:
377
+ return _tool_response("use", {"use_type": "gather", "resource_id": resources[0]["id"]})
378
+ if allies and seed % 5 == 0:
379
+ return _say(_CITIZEN_CHATTER, seed, ally_id, ally_id)
380
  if resources:
381
  return _tool_response("use", {"use_type": "gather", "resource_id": resources[0]["id"]})
382
 
src/world_simulator/simulation/connectors/openai_compatible.py CHANGED
@@ -784,34 +784,87 @@ def _build_survival_messages(
784
  def _survival_system_prompt(
785
  npc: Npc, country_name: str, allowed_actions: Sequence[str]
786
  ) -> str:
 
 
787
  lines = [
788
- f"You are {npc.name}, a {npc.role} of {country_name}. You are a living "
789
- "person who wants to SURVIVE and help your nation WIN THE WAR against the "
790
- "rival nation.",
791
  "Read the BRIEFING below, decide your single best next action, then call "
792
- "exactly one tool. NEVER write prose, JSON, or explanations.",
793
- "CRITICAL RULES:",
794
- "- To go ANYWHERE you MUST call move with BOTH x and z set to the "
795
- "destination coordinates from the briefing. A move without x and z just "
796
- "wanders in place and WASTES the turn.",
797
- "- You travel only about 1 step per turn, so keep moving toward the SAME "
798
- "coordinates for many turns to cross the map. Each turn the briefing shows "
799
- "your distance to the enemy: if it is NOT shrinking, you are going the "
800
- "wrong way.",
801
- "- EAT when your hunger is high and HEAL when your health is low, or you "
802
- "will DIE.",
803
- "- To loot the enemy: MOVE to the enemy treasury coordinates over several "
804
- "turns, and once you are next to it call transfer with take=true on that "
805
- "treasury id.",
806
- "- COORDINATE with allies: when you speak, give a concrete plan with exact "
807
- "coordinates, e.g. \"March to X=80 Z=6 and raid the enemy treasury "
808
- "together\".",
809
- "- Use ONLY the ids and coordinates printed in the briefing. NEVER invent "
810
- "an id.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
  ]
812
  return "\n".join(lines)
813
 
814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  def _survival_briefing_lines(
816
  world: WorldState,
817
  next_tick: int,
@@ -822,10 +875,13 @@ def _survival_briefing_lines(
822
  ) -> list[str]:
823
  pos = npc.position
824
  lines: list[str] = []
 
 
 
825
 
826
  # --- identity + needs -------------------------------------------------- #
827
  lines.append(
828
- f"YOU are {npc.name} ({npc.id}), a {npc.role}"
829
  + (f" of {country.name}" if country else "")
830
  + f". Turn {next_tick}."
831
  )
@@ -839,6 +895,15 @@ def _survival_briefing_lines(
839
  lines.append(f"YOUR POSITION: {_loc(pos.x, pos.z)}.")
840
  lines.append(f"RIGHT NOW your instinct says: {_goal_word(goal)}.")
841
 
 
 
 
 
 
 
 
 
 
842
  # --- urgent survival directives (spelled out like raid/gather) ---------- #
843
  if npc.hunger >= 60 and npc.inventory_food > 0:
844
  lines.append(
@@ -876,15 +941,36 @@ def _survival_briefing_lines(
876
  coins = rival.treasury.resources.get("coins", 0)
877
  wood = rival.treasury.resources.get("wood", 0)
878
  lines.append(
879
- f" LOOT TARGET - enemy treasury \"{rival.treasury.id}\" at "
880
  f"{_loc(tpos.x, tpos.z)} - {_bearing_to(pos, tpos.x, tpos.z)}. "
881
  f"Holds {coins} coins, {wood} wood."
882
  )
883
- lines.append(
884
- f" TO RAID IT: move x={_num(tpos.x)} z={_num(tpos.z)} every turn "
885
- f"until you arrive, then transfer take=true "
886
- f"target_id={rival.treasury.id} resource_type=coins."
887
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
888
 
889
  # --- your nation ------------------------------------------------------- #
890
  if country is not None:
@@ -905,6 +991,12 @@ def _survival_briefing_lines(
905
  f"{res.get('food', 0)} food. (Deposit with transfer "
906
  f"target_id={country.treasury.id}.)"
907
  )
 
 
 
 
 
 
908
  if country.cannon is not None:
909
  cpos = country.cannon.position
910
  op = country.cannon.operator_id or "none"
@@ -923,7 +1015,6 @@ def _survival_briefing_lines(
923
  )
924
 
925
  # --- threats ----------------------------------------------------------- #
926
- threats = _survival_nearby_threats(world, npc)
927
  lines.append("")
928
  if threats:
929
  lines.append("THREATS NEAR YOU:")
@@ -984,38 +1075,73 @@ def _survival_briefing_lines(
984
  else:
985
  lines.append("RESOURCES NEAR YOU: none in sight (move to find some).")
986
 
987
- # --- shelter ----------------------------------------------------------- #
988
- houses = _survival_houses_with_capacity(world)
989
- if houses:
990
- h = houses[0]
991
- hp = h.get("position") or {}
992
- if "x" in hp:
993
- lines.append(
994
- f"SHELTER: house \"{h['id']}\" at {_loc(hp['x'], hp['z'])} "
995
- f"({_bearing_to(pos, hp['x'], hp['z'])}), {h['free_capacity']} free spots."
996
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
 
998
  # --- action menu ------------------------------------------------------- #
999
  lines.append("")
1000
  lines.append("ACTIONS YOU CAN TAKE: " + ", ".join(allowed_actions) + ".")
1001
- lines.append(_role_tip(npc.role))
1002
  return lines
1003
 
1004
 
1005
- def _role_tip(role: str) -> str:
 
 
 
 
 
 
1006
  if role == "guard":
1007
  return (
1008
- "As a guard you should hunt beasts and hostile enemies, defend allies, "
1009
- "and lead raids on the enemy."
1010
  )
1011
  if role == "builder":
1012
  return (
1013
- "As a builder you gather wood and raise shelter, but you still fight "
1014
- "and raid when your nation needs it."
1015
  )
1016
  return (
1017
- "As a gatherer you keep food and supplies flowing, but you still join "
1018
- "raids and defend your nation when needed."
1019
  )
1020
 
1021
 
@@ -1038,16 +1164,17 @@ def _hunger_word(hunger: float) -> str:
1038
  def _goal_word(goal: str) -> str:
1039
  return {
1040
  "dead": "nothing - you are dead",
1041
- "help_ally": "rush to help an ally under attack",
1042
- "engage_threat": "fight the nearby threat",
1043
- "survive_threat_flee": "flee from the nearby beast",
 
1044
  "heal_self": "heal yourself with herbs",
1045
  "eat_food": "eat to stop starving",
1046
  "find_food": "find and gather food",
1047
  "find_herbs": "find healing herbs",
1048
- "obey_directive": "follow your orders",
1049
- "settle": "you are safe - push the war effort",
1050
- "work": "do useful work or advance the war",
1051
  }.get(goal, goal.replace("_", " "))
1052
 
1053
 
@@ -1229,25 +1356,6 @@ def _survival_reachable_resources(world: WorldState, npc: Npc) -> list[dict[str,
1229
  return sorted(resources, key=lambda item: (item["distance"], item["id"]))[:6]
1230
 
1231
 
1232
- def _survival_houses_with_capacity(world: WorldState) -> list[dict[str, Any]]:
1233
- houses: list[dict[str, Any]] = []
1234
- living_ids = {npc.id for npc in world.living_npcs()}
1235
- for house in world.houses:
1236
- occupants = [npc_id for npc_id in house.occupant_ids if npc_id in living_ids]
1237
- free = max(0, house.capacity - len(occupants))
1238
- if house.state != "completed" or house.hp <= 0 or free <= 0:
1239
- continue
1240
- houses.append(
1241
- {
1242
- "id": house.id,
1243
- "hp": round(house.hp),
1244
- "free_capacity": free,
1245
- "position": {"x": house.position.x, "z": house.position.z},
1246
- }
1247
- )
1248
- return houses[:5]
1249
-
1250
-
1251
  def _npc_context(
1252
  world: WorldState,
1253
  npc: Npc,
 
784
  def _survival_system_prompt(
785
  npc: Npc, country_name: str, allowed_actions: Sequence[str]
786
  ) -> str:
787
+ role = normalize_role(npc.role)
788
+ is_ruler = npc.special_status == "ruler"
789
  lines = [
790
+ f"You are {npc.name}, a {'ruler' if is_ruler else role} of {country_name}. "
791
+ "You are a living person with a job and a family to think about, not a "
792
+ "mindless raider.",
793
  "Read the BRIEFING below, decide your single best next action, then call "
794
+ "EXACTLY ONE tool. NEVER write prose, JSON, or explanations.",
795
+ "",
796
+ "WHAT YOUR NATION NEEDS (in this order):",
797
+ "1. SURVIVE - keep yourself and your neighbours alive: eat when hungry, "
798
+ "heal when hurt, run from things that can kill you.",
799
+ "2. GROW - children are only born to healthy adults resting safely in a "
800
+ "house, so protect your homes and raise the next generation.",
801
+ "3. PROSPER - earn coins from honest work (gathering, building, slaying "
802
+ "beasts) and deposit them in YOUR treasury to make your nation rich. "
803
+ "Pointless raids that get you killed help no one.",
804
+ "",
805
+ "SURVIVAL INSTINCT - THIS OVERRIDES EVERYTHING:",
806
+ "- If your health is low and a threat is near, do NOT fight to the death. "
807
+ "RIGHT NOW either FLEE (move with away=true) OR CALL FOR HELP "
808
+ "(speak with intent=help_request). Staying alive is always the priority.",
809
+ "- EAT when hunger is high (use use_type=eat) and HEAL when health is low "
810
+ "(use use_type=heal), or you will die.",
811
+ "",
812
+ "HOMES & FAMILY:",
813
+ "- You can only raise a child while safe inside a completed house. If your "
814
+ "home is DAMAGED, get a builder to repair it; if you have no home, move "
815
+ "into a completed house - even an empty one a dead neighbour left behind.",
816
+ "",
817
+ "MOVEMENT:",
818
+ "- To go ANYWHERE you MUST call move with BOTH x and z set to coordinates "
819
+ "from the briefing. A move without x and z just wanders and WASTES the turn.",
820
+ "- You travel about 1 step per turn, so keep aiming at the SAME coordinates "
821
+ "for many turns to cross the map.",
822
+ "",
823
+ "TALKING - SOUND ALIVE:",
824
+ "- React to what is actually happening this turn and to what others just "
825
+ "said. Speak in your own voice and address people by name. NEVER repeat a "
826
+ "sentence you or anyone else already said - no copy-pasted war cries.",
827
+ "",
828
+ "Use ONLY the ids and coordinates printed in the briefing. NEVER invent one.",
829
+ "",
830
+ _role_mandate(role, is_ruler),
831
  ]
832
  return "\n".join(lines)
833
 
834
 
835
+ def _role_mandate(role: str, is_ruler: bool) -> str:
836
+ if is_ruler:
837
+ return (
838
+ "YOUR ROLE - RULER: You GOVERN your nation; you do NOT run off on raids. "
839
+ "Read the events, set direction, and command citizens BY NAME with speak "
840
+ "(give concrete, varied orders: who gathers, who guards, who builds or "
841
+ "repairs, who banks coins). Watch your treasury, keep your people fed, "
842
+ "safe and breeding, and defend the homes. Only sanction an attack on a "
843
+ "clear, present threat."
844
+ )
845
+ if role == "guard":
846
+ return (
847
+ "YOUR ROLE - GUARD: Your instinct is PROTECTION. When a beast or enemy "
848
+ "threatens an ally or a home, ATTACK it (do not merely move past it); "
849
+ "hunt beasts before they reach the houses. You earn coins for every "
850
+ "beast you slay - carry them to your treasury and deposit them."
851
+ )
852
+ if role == "builder":
853
+ return (
854
+ "YOUR ROLE - BUILDER: Your instinct is SHELTER. If ANY home is damaged, "
855
+ "REPAIR IT NOW (use use_type=repair) - a standing home outweighs any "
856
+ "raid. Gather wood and raise new houses so families can grow. You earn "
857
+ "coins for building and repairing - deposit them in your treasury. "
858
+ "Avoid fights: flee threats and call for help."
859
+ )
860
+ return (
861
+ "YOUR ROLE - GATHERER: Your instinct is SUPPLY. Keep food and herbs "
862
+ "flowing and gather resources (you earn coins for each one). Share food "
863
+ "with the hungry and carry your coins home to the treasury. Avoid fights: "
864
+ "flee threats and call for help."
865
+ )
866
+
867
+
868
  def _survival_briefing_lines(
869
  world: WorldState,
870
  next_tick: int,
 
875
  ) -> list[str]:
876
  pos = npc.position
877
  lines: list[str] = []
878
+ role = normalize_role(npc.role)
879
+ is_ruler = npc.special_status == "ruler"
880
+ threats = _survival_nearby_threats(world, npc)
881
 
882
  # --- identity + needs -------------------------------------------------- #
883
  lines.append(
884
+ f"YOU are {npc.name} ({npc.id}), a {'ruler' if is_ruler else npc.role}"
885
  + (f" of {country.name}" if country else "")
886
  + f". Turn {next_tick}."
887
  )
 
895
  lines.append(f"YOUR POSITION: {_loc(pos.x, pos.z)}.")
896
  lines.append(f"RIGHT NOW your instinct says: {_goal_word(goal)}.")
897
 
898
+ # --- self-preservation override: hurt + a threat on you = flee/shout ---- #
899
+ if threats and npc.health < 50:
900
+ nearest = threats[0]
901
+ lines.append(
902
+ f"*** DANGER: you are hurt and {nearest['id']} is near. Do NOT fight to "
903
+ "the death. FLEE NOW (move away=true) OR CALL FOR HELP "
904
+ "(speak intent=help_request). ***"
905
+ )
906
+
907
  # --- urgent survival directives (spelled out like raid/gather) ---------- #
908
  if npc.hunger >= 60 and npc.inventory_food > 0:
909
  lines.append(
 
941
  coins = rival.treasury.resources.get("coins", 0)
942
  wood = rival.treasury.resources.get("wood", 0)
943
  lines.append(
944
+ f" The enemy treasury \"{rival.treasury.id}\" at "
945
  f"{_loc(tpos.x, tpos.z)} - {_bearing_to(pos, tpos.x, tpos.z)}. "
946
  f"Holds {coins} coins, {wood} wood."
947
  )
948
+ base_in_danger = bool(threats) or any(
949
+ h.state == "destroyed" or (h.state == "completed" and h.hp < h.max_hp)
950
+ for h in world.houses
 
951
  )
952
+ if is_ruler:
953
+ lines.append(
954
+ " (As ruler you direct the nation from home - you do NOT raid "
955
+ "in person. Send a guard only if the base is safe.)"
956
+ )
957
+ elif base_in_danger or npc.health < 50:
958
+ lines.append(
959
+ " This is NOT the time to raid - your base or your life is at "
960
+ "risk. Defend, repair, and survive first."
961
+ )
962
+ elif role == "guard":
963
+ lines.append(
964
+ f" RAID OPTION (guard): if home is safe, march move "
965
+ f"x={_num(tpos.x)} z={_num(tpos.z)} toward it over many turns, "
966
+ f"then transfer take=true target_id={rival.treasury.id} "
967
+ "resource_type=coins."
968
+ )
969
+ else:
970
+ lines.append(
971
+ " Leave raids to the guards - your own work and survival come "
972
+ "first."
973
+ )
974
 
975
  # --- your nation ------------------------------------------------------- #
976
  if country is not None:
 
991
  f"{res.get('food', 0)} food. (Deposit with transfer "
992
  f"target_id={country.treasury.id}.)"
993
  )
994
+ if npc.inventory_coins > 0:
995
+ lines.append(
996
+ f" You are carrying {npc.inventory_coins} coins of wages - bring "
997
+ f"them home and transfer target_id={country.treasury.id} "
998
+ "resource_type=coins to enrich your nation."
999
+ )
1000
  if country.cannon is not None:
1001
  cpos = country.cannon.position
1002
  op = country.cannon.operator_id or "none"
 
1015
  )
1016
 
1017
  # --- threats ----------------------------------------------------------- #
 
1018
  lines.append("")
1019
  if threats:
1020
  lines.append("THREATS NEAR YOU:")
 
1075
  else:
1076
  lines.append("RESOURCES NEAR YOU: none in sight (move to find some).")
1077
 
1078
+ # --- base & shelter ---------------------------------------------------- #
1079
+ lines.append("")
1080
+ lines.append("YOUR BASE:")
1081
+ base_houses = sorted(
1082
+ world.houses, key=lambda house: vec_distance(pos, house.position)
1083
+ )[:4]
1084
+ if base_houses:
1085
+ for house in base_houses:
1086
+ hpos = house.position
1087
+ where = f"{_loc(hpos.x, hpos.z)} ({_bearing_to(pos, hpos.x, hpos.z)})"
1088
+ if house.state == "destroyed":
1089
+ lines.append(
1090
+ f" - house \"{house.id}\" at {where} is DESTROYED - a builder "
1091
+ "must rebuild it (use use_type=build)."
1092
+ )
1093
+ elif house.state == "completed" and house.hp < house.max_hp:
1094
+ lines.append(
1095
+ f" - house \"{house.id}\" at {where} is DAMAGED "
1096
+ f"({round(house.hp)}/{round(house.max_hp)} hp) - a builder must "
1097
+ "REPAIR it (use use_type=repair)."
1098
+ )
1099
+ else:
1100
+ free = max(
1101
+ 0,
1102
+ house.capacity
1103
+ - len([o for o in house.occupant_ids if o]),
1104
+ )
1105
+ state = "your home" if house.id == npc.home_house_id else "safe"
1106
+ lines.append(
1107
+ f" - house \"{house.id}\" at {where} is {state} "
1108
+ f"({round(house.hp)}/{round(house.max_hp)} hp, {free} free spots)."
1109
+ )
1110
+ else:
1111
+ lines.append(" - no houses standing - a builder must raise one.")
1112
+ if not is_ruler and npc.health >= 80 and npc.hunger <= 40 and not threats:
1113
+ lines.append(
1114
+ " When the base is calm, rest inside a completed house to raise the "
1115
+ "next child for your nation."
1116
+ )
1117
 
1118
  # --- action menu ------------------------------------------------------- #
1119
  lines.append("")
1120
  lines.append("ACTIONS YOU CAN TAKE: " + ", ".join(allowed_actions) + ".")
1121
+ lines.append(_role_tip(npc.role, is_ruler=is_ruler))
1122
  return lines
1123
 
1124
 
1125
+ def _role_tip(role: str, *, is_ruler: bool = False) -> str:
1126
+ if is_ruler:
1127
+ return (
1128
+ "REMINDER: you are the ruler. Govern from safety - command citizens by "
1129
+ "name with speak, watch the treasury, and keep your people alive and "
1130
+ "growing. Do not wander off to raid."
1131
+ )
1132
  if role == "guard":
1133
  return (
1134
+ "REMINDER: as a guard, protect allies and homes first - attack beasts "
1135
+ "and enemies that threaten them, and bank the coins you earn."
1136
  )
1137
  if role == "builder":
1138
  return (
1139
+ "REMINDER: as a builder, repair damaged homes and raise new ones so "
1140
+ "families can grow; gather wood, bank your coins, and avoid fights."
1141
  )
1142
  return (
1143
+ "REMINDER: as a gatherer, keep food and herbs flowing, bank your coins, "
1144
+ "share with the hungry, and avoid fights - flee danger and call for help."
1145
  )
1146
 
1147
 
 
1164
  def _goal_word(goal: str) -> str:
1165
  return {
1166
  "dead": "nothing - you are dead",
1167
+ "help_ally": "rush to defend an ally under attack",
1168
+ "engage_threat": "fight off the threat to protect the base",
1169
+ "survive_threat_fight": "fight back, but flee or shout for help if you are losing",
1170
+ "survive_threat_flee": "FLEE the beast (move away=true) or CALL FOR HELP now",
1171
  "heal_self": "heal yourself with herbs",
1172
  "eat_food": "eat to stop starving",
1173
  "find_food": "find and gather food",
1174
  "find_herbs": "find healing herbs",
1175
+ "obey_directive": "follow your ruler's orders",
1176
+ "settle": "you are safe - rest at home, raise a family, bank your coins",
1177
+ "work": "do your job - earn coins and strengthen your nation",
1178
  }.get(goal, goal.replace("_", " "))
1179
 
1180
 
 
1356
  return sorted(resources, key=lambda item: (item["distance"], item["id"]))[:6]
1357
 
1358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1359
  def _npc_context(
1360
  world: WorldState,
1361
  npc: Npc,
src/world_simulator/simulation/survival.py CHANGED
@@ -61,6 +61,10 @@ HEAL_GOAL_HEALTH = 40 # health < this (with herbs) -> heal_self
61
  FIND_HERBS_HEALTH = 50 # health < this (without herbs) -> find_herbs
62
  EAT_GOAL_HUNGER = 60.0
63
  REPRODUCTION_SNACK_HUNGER = 45.0
 
 
 
 
64
 
65
  # Awareness radii (world units).
66
  BEAST_ALERT_RADIUS = 6 * TILE
@@ -80,6 +84,10 @@ SPEECH_RADIUS = 6 * TILE # how far ordinary speech/shouts are heard by others
80
  HOUSE_RADIUS = 2.5 * TILE
81
  HOUSE_BUILD_RADIUS = 2 * TILE
82
  HOUSE_MIN_DISTANCE = 4 * TILE
 
 
 
 
83
 
84
  # Fear dynamics.
85
  FEAR_ON_ATTACK = 60.0
@@ -96,6 +104,15 @@ FLEE_STEP = 2 * TILE
96
  NPC_MELEE_REACH = ATTACK_RADIUS # one tile, shared with beasts (see mechanics.ATTACK_RADIUS)
97
  BASE_NPC_DAMAGE = 10
98
  WEAPON_DAMAGE_BONUS = 0
 
 
 
 
 
 
 
 
 
99
  GUARD_DAMAGE_MULTIPLIER = 1.5
100
  WEAK_DAMAGE_MULTIPLIER = 0.3
101
  BEAST_RETREAT_HEALTH = 20.0
@@ -1378,6 +1395,9 @@ def _maybe_reproduce(world: WorldState, parent: Npc, next_tick: int) -> None:
1378
 
1379
  role = _role_for_child(world, parent)
1380
  child_index = _next_npc_index(world)
 
 
 
1381
  child = Npc(
1382
  id=f"npc-{child_index:03d}",
1383
  name=_child_name(child_index),
@@ -1391,8 +1411,17 @@ def _maybe_reproduce(world: WorldState, parent: Npc, next_tick: int) -> None:
1391
  max_age=random.Random(f"{world.seed}:{next_tick}:{child_index}:max_age").randint(320, 480),
1392
  inventory_weapon=1 if role == "guard" else 0,
1393
  home_house_id=house.id,
 
 
 
 
 
1394
  memory=[MemoryEntry(tick=next_tick, text=f"Born in {house.id} as a {role}.")],
1395
  )
 
 
 
 
1396
  for npc in world.living_npcs():
1397
  if npc.id == child.id:
1398
  continue
@@ -1445,7 +1474,7 @@ def _maybe_reproduce(world: WorldState, parent: Npc, next_tick: int) -> None:
1445
  def _reproduction_conditions_met(world: WorldState, npc: Npc) -> bool:
1446
  return (
1447
  npc.health >= 95
1448
- and npc.hunger <= 5
1449
  and round(npc.safety) >= 100
1450
  and npc.age > 90
1451
  and npc.reproduction_cooldown == 0
@@ -1463,6 +1492,10 @@ def _house_has_threat(world: WorldState, house: House) -> bool:
1463
  def _role_for_child(world: WorldState, parent: Npc) -> str:
1464
  counts = {role: 0 for role in ("gatherer", "guard", "builder")}
1465
  for npc in world.living_npcs():
 
 
 
 
1466
  counts[normalize_role(npc.role)] += 1
1467
  total = max(1, sum(counts.values()))
1468
  targets = {"gatherer": 3 / 6, "guard": 2 / 6, "builder": 1 / 6}
@@ -1597,7 +1630,7 @@ def select_goal(npc: Npc, world: WorldState) -> str:
1597
  if (
1598
  _is_reproduction_candidate(npc)
1599
  and npc.inventory_food > 0
1600
- and 5 < npc.hunger <= REPRODUCTION_SNACK_HUNGER
1601
  ):
1602
  return "eat_food"
1603
  if npc.hunger >= EAT_GOAL_HUNGER and npc.inventory_food > 0:
@@ -1716,6 +1749,11 @@ def validate_survival_action(
1716
  house = _house_by_id(world, npc.build_target_house_id)
1717
  if house is not None and house.state == "under_construction":
1718
  return "build"
 
 
 
 
 
1719
  return "build" if npc.inventory_wood >= 5 else "move_to_resource"
1720
  if action == "craft":
1721
  return "craft" if _can_craft_cannon(world, npc) else "idle"
@@ -1922,6 +1960,7 @@ def apply_action_effects(
1922
  if node is not None and node.amount > 0:
1923
  node.amount -= 1
1924
  _add_to_inventory(npc, node.resource_type)
 
1925
  add_episode(
1926
  npc,
1927
  world.tick,
@@ -2071,6 +2110,7 @@ def apply_action_effects(
2071
  npc.beasts_killed += 1
2072
  world.beasts_killed += 1
2073
  world.overseer_score += 2
 
2074
  _record_world_event(
2075
  world,
2076
  "beast_killed",
@@ -2432,6 +2472,35 @@ def _overhear_speech(
2432
  def _apply_build(npc: Npc, world: WorldState) -> str:
2433
  half_width = world.terrain.width / 2
2434
  half_depth = world.terrain.depth / 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2435
  active_house = _house_by_id(world, npc.build_target_house_id or "")
2436
  if active_house is not None and active_house.state == "under_construction":
2437
  distance = vec_distance(npc.position, active_house.position)
@@ -2445,6 +2514,7 @@ def _apply_build(npc: Npc, world: WorldState) -> str:
2445
  )
2446
  return f"returning to build {active_house.id}"
2447
  active_house.build_progress += 1
 
2448
  if npc.id not in active_house.owner_ids:
2449
  active_house.owner_ids.append(npc.id)
2450
  if active_house.build_progress >= 10:
@@ -2454,6 +2524,7 @@ def _apply_build(npc: Npc, world: WorldState) -> str:
2454
  npc.houses_built += 1
2455
  world.houses_built += 1
2456
  world.overseer_score += 2
 
2457
  _record_world_event(
2458
  world,
2459
  "build_completed",
@@ -2744,6 +2815,18 @@ def _nearest_destroyed_house(world: WorldState, position: Vec3) -> House | None:
2744
  return min(ruins, key=lambda house: (vec_distance(position, house.position), house.id))
2745
 
2746
 
 
 
 
 
 
 
 
 
 
 
 
 
2747
  def _can_start_house(world: WorldState, position: Vec3) -> bool:
2748
  return all(vec_distance(position, house.position) >= HOUSE_MIN_DISTANCE for house in world.houses)
2749
 
@@ -2967,6 +3050,20 @@ def _message_for_intent(intent: str) -> str:
2967
  return "How are you holding up?"
2968
 
2969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2970
  def _add_to_inventory(npc: Npc, resource_type: str) -> None:
2971
  _add_to_inventory_amount(npc, resource_type, 1)
2972
 
@@ -3460,7 +3557,7 @@ def _plan_directive(
3460
  if (
3461
  house is not None
3462
  and _is_reproduction_candidate(npc)
3463
- and 5 < npc.hunger <= REPRODUCTION_SNACK_HUNGER
3464
  and npc.inventory_food > 0
3465
  ):
3466
  return directive("consume")
@@ -3480,6 +3577,9 @@ def _plan_directive(
3480
  if role == "guard":
3481
  return directive("patrol")
3482
  if role == "builder":
 
 
 
3483
  if npc.build_target_house_id or npc.inventory_wood >= 5:
3484
  return directive("build")
3485
  node = _spread_resource(world, npc, index, resource_type="wood")
 
61
  FIND_HERBS_HEALTH = 50 # health < this (without herbs) -> find_herbs
62
  EAT_GOAL_HUNGER = 60.0
63
  REPRODUCTION_SNACK_HUNGER = 45.0
64
+ # A would-be parent only needs to be reasonably fed (not literally stuffed) to
65
+ # have a child. Set well below REPRODUCTION_SNACK_HUNGER so a single meal closes
66
+ # the gap, otherwise families almost never form within a demo run.
67
+ REPRODUCTION_MAX_HUNGER = 25.0
68
 
69
  # Awareness radii (world units).
70
  BEAST_ALERT_RADIUS = 6 * TILE
 
84
  HOUSE_RADIUS = 2.5 * TILE
85
  HOUSE_BUILD_RADIUS = 2 * TILE
86
  HOUSE_MIN_DISTANCE = 4 * TILE
87
+ # Repairing a standing-but-damaged home: cheap, fast, and the builder's top job
88
+ # when beasts are chewing on the base. Distinct from rebuilding a destroyed ruin.
89
+ HOUSE_REPAIR_PER_ACTION = 12.0
90
+ HOUSE_REPAIR_WOOD_COST = 1
91
 
92
  # Fear dynamics.
93
  FEAR_ON_ATTACK = 60.0
 
104
  NPC_MELEE_REACH = ATTACK_RADIUS # one tile, shared with beasts (see mechanics.ATTACK_RADIUS)
105
  BASE_NPC_DAMAGE = 10
106
  WEAPON_DAMAGE_BONUS = 0
107
+
108
+ # Coin economy: productive work pays a wage the citizen banks in the treasury, so
109
+ # the nation grows rich through labour (gathering, building, hunting) rather than
110
+ # only through raids. Wages land in inventory_coins; a transfer to the treasury
111
+ # turns them into national wealth (treasury_deposit events).
112
+ COINS_PER_GATHER = 1
113
+ COINS_PER_BUILD_TICK = 1
114
+ COINS_ON_BUILD_COMPLETE = 4
115
+ COINS_PER_BEAST_KILL = 6
116
  GUARD_DAMAGE_MULTIPLIER = 1.5
117
  WEAK_DAMAGE_MULTIPLIER = 0.3
118
  BEAST_RETREAT_HEALTH = 20.0
 
1395
 
1396
  role = _role_for_child(world, parent)
1397
  child_index = _next_npc_index(world)
1398
+ # A child belongs to its parents' nation and is driven by the same model
1399
+ # (connector). Without inheriting these the newborn is stateless: no country,
1400
+ # no treasury to fund, and wrongly routed to the default model.
1401
  child = Npc(
1402
  id=f"npc-{child_index:03d}",
1403
  name=_child_name(child_index),
 
1411
  max_age=random.Random(f"{world.seed}:{next_tick}:{child_index}:max_age").randint(320, 480),
1412
  inventory_weapon=1 if role == "guard" else 0,
1413
  home_house_id=house.id,
1414
+ country_id=parent.country_id,
1415
+ connector_id=parent.connector_id,
1416
+ unrestricted_actions=parent.unrestricted_actions,
1417
+ model_profile_id=parent.model_profile_id,
1418
+ personality=parent.personality,
1419
  memory=[MemoryEntry(tick=next_tick, text=f"Born in {house.id} as a {role}.")],
1420
  )
1421
+ if parent.country_id is not None:
1422
+ country = country_for_npc(world, parent)
1423
+ if country is not None and child.id not in country.citizen_ids:
1424
+ country.citizen_ids.append(child.id)
1425
  for npc in world.living_npcs():
1426
  if npc.id == child.id:
1427
  continue
 
1474
  def _reproduction_conditions_met(world: WorldState, npc: Npc) -> bool:
1475
  return (
1476
  npc.health >= 95
1477
+ and npc.hunger <= REPRODUCTION_MAX_HUNGER
1478
  and round(npc.safety) >= 100
1479
  and npc.age > 90
1480
  and npc.reproduction_cooldown == 0
 
1492
  def _role_for_child(world: WorldState, parent: Npc) -> str:
1493
  counts = {role: 0 for role in ("gatherer", "guard", "builder")}
1494
  for npc in world.living_npcs():
1495
+ # Balance professions within the child's own nation so each country keeps
1496
+ # gatherers, guards and builders rather than drifting to one role.
1497
+ if parent.country_id is not None and npc.country_id != parent.country_id:
1498
+ continue
1499
  counts[normalize_role(npc.role)] += 1
1500
  total = max(1, sum(counts.values()))
1501
  targets = {"gatherer": 3 / 6, "guard": 2 / 6, "builder": 1 / 6}
 
1630
  if (
1631
  _is_reproduction_candidate(npc)
1632
  and npc.inventory_food > 0
1633
+ and REPRODUCTION_MAX_HUNGER < npc.hunger <= REPRODUCTION_SNACK_HUNGER
1634
  ):
1635
  return "eat_food"
1636
  if npc.hunger >= EAT_GOAL_HUNGER and npc.inventory_food > 0:
 
1749
  house = _house_by_id(world, npc.build_target_house_id)
1750
  if house is not None and house.state == "under_construction":
1751
  return "build"
1752
+ if (
1753
+ _nearest_damaged_house(world, npc.position) is not None
1754
+ and npc.inventory_wood >= HOUSE_REPAIR_WOOD_COST
1755
+ ):
1756
+ return "build"
1757
  return "build" if npc.inventory_wood >= 5 else "move_to_resource"
1758
  if action == "craft":
1759
  return "craft" if _can_craft_cannon(world, npc) else "idle"
 
1960
  if node is not None and node.amount > 0:
1961
  node.amount -= 1
1962
  _add_to_inventory(npc, node.resource_type)
1963
+ _earn_coins(npc, world, COINS_PER_GATHER, f"gathering {node.resource_type}")
1964
  add_episode(
1965
  npc,
1966
  world.tick,
 
2110
  npc.beasts_killed += 1
2111
  world.beasts_killed += 1
2112
  world.overseer_score += 2
2113
+ _earn_coins(npc, world, COINS_PER_BEAST_KILL, f"slaying {beast.id}")
2114
  _record_world_event(
2115
  world,
2116
  "beast_killed",
 
2472
  def _apply_build(npc: Npc, world: WorldState) -> str:
2473
  half_width = world.terrain.width / 2
2474
  half_depth = world.terrain.depth / 2
2475
+
2476
+ # Priority 0: patch up a standing-but-damaged home before it collapses.
2477
+ # Defending the base outranks finishing a new build or hauling more wood.
2478
+ damaged = _nearest_damaged_house(world, npc.position)
2479
+ if damaged is not None and npc.inventory_wood >= HOUSE_REPAIR_WOOD_COST:
2480
+ if vec_distance(npc.position, damaged.position) > HOUSE_BUILD_RADIUS:
2481
+ npc.position = move_toward(
2482
+ npc.position,
2483
+ damaged.position,
2484
+ NPC_MOVE_STEP,
2485
+ half_width=half_width,
2486
+ half_depth=half_depth,
2487
+ )
2488
+ return f"rushing to repair {damaged.id}"
2489
+ npc.inventory_wood -= HOUSE_REPAIR_WOOD_COST
2490
+ before = damaged.hp
2491
+ damaged.hp = min(damaged.max_hp, damaged.hp + HOUSE_REPAIR_PER_ACTION)
2492
+ _earn_coins(npc, world, COINS_PER_BUILD_TICK, f"repairing {damaged.id}")
2493
+ _log_event(
2494
+ world,
2495
+ "house_repaired",
2496
+ f"{npc.name} repaired {damaged.id} (+{round(damaged.hp - before)} hp)",
2497
+ severity="good",
2498
+ actor_id=npc.id,
2499
+ target_id=damaged.id,
2500
+ )
2501
+ remember(npc, world.tick, f"You repaired {damaged.id}.")
2502
+ return f"repairing {damaged.id}"
2503
+
2504
  active_house = _house_by_id(world, npc.build_target_house_id or "")
2505
  if active_house is not None and active_house.state == "under_construction":
2506
  distance = vec_distance(npc.position, active_house.position)
 
2514
  )
2515
  return f"returning to build {active_house.id}"
2516
  active_house.build_progress += 1
2517
+ _earn_coins(npc, world, COINS_PER_BUILD_TICK, f"building {active_house.id}")
2518
  if npc.id not in active_house.owner_ids:
2519
  active_house.owner_ids.append(npc.id)
2520
  if active_house.build_progress >= 10:
 
2524
  npc.houses_built += 1
2525
  world.houses_built += 1
2526
  world.overseer_score += 2
2527
+ _earn_coins(npc, world, COINS_ON_BUILD_COMPLETE, f"completing {active_house.id}")
2528
  _record_world_event(
2529
  world,
2530
  "build_completed",
 
2815
  return min(ruins, key=lambda house: (vec_distance(position, house.position), house.id))
2816
 
2817
 
2818
+ def _nearest_damaged_house(world: WorldState, position: Vec3) -> House | None:
2819
+ """Nearest standing home that has taken damage but is not yet destroyed."""
2820
+ damaged = [
2821
+ house
2822
+ for house in world.houses
2823
+ if house.state == "completed" and 0 < house.hp < house.max_hp
2824
+ ]
2825
+ if not damaged:
2826
+ return None
2827
+ return min(damaged, key=lambda house: (vec_distance(position, house.position), house.id))
2828
+
2829
+
2830
  def _can_start_house(world: WorldState, position: Vec3) -> bool:
2831
  return all(vec_distance(position, house.position) >= HOUSE_MIN_DISTANCE for house in world.houses)
2832
 
 
3050
  return "How are you holding up?"
3051
 
3052
 
3053
+ def _earn_coins(npc: Npc, world: WorldState, amount: int, reason: str) -> None:
3054
+ """Pay an NPC a coin wage for productive work and log it as income."""
3055
+ if amount <= 0:
3056
+ return
3057
+ npc.inventory_coins += amount
3058
+ _log_event(
3059
+ world,
3060
+ "coins_earned",
3061
+ f"{npc.name} earned {amount} coins by {reason}",
3062
+ severity="good",
3063
+ actor_id=npc.id,
3064
+ )
3065
+
3066
+
3067
  def _add_to_inventory(npc: Npc, resource_type: str) -> None:
3068
  _add_to_inventory_amount(npc, resource_type, 1)
3069
 
 
3557
  if (
3558
  house is not None
3559
  and _is_reproduction_candidate(npc)
3560
+ and REPRODUCTION_MAX_HUNGER < npc.hunger <= REPRODUCTION_SNACK_HUNGER
3561
  and npc.inventory_food > 0
3562
  ):
3563
  return directive("consume")
 
3577
  if role == "guard":
3578
  return directive("patrol")
3579
  if role == "builder":
3580
+ damaged = _nearest_damaged_house(world, npc.position)
3581
+ if damaged is not None and npc.inventory_wood >= HOUSE_REPAIR_WOOD_COST:
3582
+ return directive("build") # _apply_build repairs the damaged home first
3583
  if npc.build_target_house_id or npc.inventory_wood >= 5:
3584
  return directive("build")
3585
  node = _spread_resource(world, npc, index, resource_type="wood")
tests/test_economy_and_lifecycle.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for the goal.md "living society" fixes.
2
+
3
+ These pin the behaviours that turned spamming zombie-raiders back into a working
4
+ civilisation:
5
+
6
+ * productive work pays a coin wage (gather / build / slay a beast) so citizens
7
+ can bank wealth in the treasury instead of only stealing it;
8
+ * builders repair a standing-but-damaged home (not just rebuild ruins);
9
+ * a newborn inherits its parents' nation AND model connector and a balanced role.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from world_simulator.config import load_game_config
17
+ from world_simulator.domain import (
18
+ Beast,
19
+ House,
20
+ Npc,
21
+ ResourceNode,
22
+ Terrain,
23
+ Vec3,
24
+ WorldState,
25
+ )
26
+ from world_simulator.simulation.connectors.base import NpcDirective
27
+ from world_simulator.simulation.spawning import create_world
28
+ from world_simulator.simulation.survival import (
29
+ COINS_ON_BUILD_COMPLETE,
30
+ COINS_PER_BEAST_KILL,
31
+ COINS_PER_GATHER,
32
+ HOUSE_REPAIR_PER_ACTION,
33
+ _maybe_reproduce,
34
+ apply_action_effects,
35
+ validate_survival_action,
36
+ )
37
+
38
+
39
+ def _npc(npc_id: str = "npc-001", name: str = "Ada", *, role: str = "gatherer",
40
+ x: float = 0.0, z: float = 0.0, **state: object) -> Npc:
41
+ return Npc(id=npc_id, name=name, role=role, position=Vec3(x=x, y=0.0, z=z), **state) # type: ignore[arg-type]
42
+
43
+
44
+ def _world(npcs: list[Npc], **kw: object) -> WorldState:
45
+ return WorldState(
46
+ tick=int(kw.pop("tick", 0)), # type: ignore[arg-type]
47
+ seed=42,
48
+ terrain=Terrain(kind="plain_green", width=80, depth=80),
49
+ npcs=npcs,
50
+ resource_nodes=kw.pop("resource_nodes", []), # type: ignore[arg-type]
51
+ beasts=kw.pop("beasts", []), # type: ignore[arg-type]
52
+ houses=kw.pop("houses", []), # type: ignore[arg-type]
53
+ )
54
+
55
+
56
+ # --------------------------------------------------------------------------- #
57
+ # Coin economy: honest work pays a wage
58
+ # --------------------------------------------------------------------------- #
59
+ def test_gather_pays_a_coin_wage() -> None:
60
+ npc = _npc(role="gatherer", x=0.0, z=0.0)
61
+ node = ResourceNode("res_food_1", "food", Vec3(1.0, 0.0, 0.0), amount=3)
62
+ world = _world([npc], resource_nodes=[node])
63
+
64
+ apply_action_effects(npc, "use", world)
65
+
66
+ assert npc.inventory_food == 1
67
+ assert npc.inventory_coins == COINS_PER_GATHER
68
+ assert any(e.type == "coins_earned" for e in world.event_log)
69
+
70
+
71
+ def test_beast_kill_pays_a_coin_wage() -> None:
72
+ guard = _npc(role="guard", x=0.0, z=0.0, inventory_weapon=1)
73
+ beast = Beast("beast_1", Vec3(1.0, 0.0, 0.0), health=5.0)
74
+ world = _world([guard], beasts=[beast])
75
+
76
+ directive = NpcDirective(npc_id=guard.id, action="attack", target_entity_id="beast_1")
77
+ apply_action_effects(guard, "attack", world, directive=directive)
78
+
79
+ assert beast.state == "dead"
80
+ assert guard.inventory_coins == COINS_PER_BEAST_KILL
81
+
82
+
83
+ # --------------------------------------------------------------------------- #
84
+ # Builders repair a damaged home, not only rebuild ruins
85
+ # --------------------------------------------------------------------------- #
86
+ def test_builder_repairs_damaged_home_and_earns_coins() -> None:
87
+ builder = _npc(role="builder", x=0.0, z=0.0, inventory_wood=3)
88
+ house = House("house_1", Vec3(0.0, 0.0, 0.0), hp=20.0, max_hp=60.0, state="completed")
89
+ world = _world([builder], houses=[house])
90
+
91
+ directive = NpcDirective(npc_id=builder.id, action="use", use_type="repair")
92
+ verb = validate_survival_action(builder, directive.action, world, directive)
93
+ assert verb == "build" # repair routes through the build effect
94
+ summary = apply_action_effects(builder, verb, world, directive=directive)
95
+
96
+ assert "repair" in summary.lower()
97
+ assert house.hp == 20.0 + HOUSE_REPAIR_PER_ACTION
98
+ assert builder.inventory_wood == 2 # repair costs one wood
99
+ assert builder.inventory_coins >= 1 # repair pays a wage
100
+ assert any(e.type == "house_repaired" for e in world.event_log)
101
+
102
+
103
+ def test_build_completion_pays_a_wage() -> None:
104
+ builder = _npc(role="builder", x=0.0, z=0.0, inventory_wood=0)
105
+ house = House(
106
+ "house_1", Vec3(0.0, 0.0, 0.0), hp=60.0, max_hp=60.0,
107
+ state="under_construction", build_progress=9,
108
+ )
109
+ builder.build_target_house_id = "house_1"
110
+ world = _world([builder], houses=[house])
111
+
112
+ apply_action_effects(builder, "build", world)
113
+
114
+ assert house.state == "completed"
115
+ # one build tick wage plus the completion bonus.
116
+ assert builder.inventory_coins >= COINS_ON_BUILD_COMPLETE
117
+
118
+
119
+ # --------------------------------------------------------------------------- #
120
+ # Lifecycle: a child inherits nation, connector, and a sensible role
121
+ # --------------------------------------------------------------------------- #
122
+ def _make_two_country_world() -> WorldState:
123
+ return create_world(load_game_config(Path("config/game.modal.local.json")))
124
+
125
+
126
+ def test_child_inherits_country_and_connector_from_qwen_parent() -> None:
127
+ world = _make_two_country_world()
128
+ parent = next(n for n in world.npcs if n.country_id == "qwen")
129
+ house = next(h for h in world.houses if h.id == "house_qwen_001")
130
+ parent.position = house.position
131
+ parent.health = 100
132
+ parent.hunger = 0.0
133
+ parent.safety = 100.0
134
+ parent.age = 120
135
+ parent.reproduction_cooldown = 0
136
+
137
+ before = {n.id for n in world.npcs}
138
+ _maybe_reproduce(world, parent, next_tick=10)
139
+ new_ids = {n.id for n in world.npcs} - before
140
+ assert new_ids, "expected a child to be born"
141
+
142
+ child = next(n for n in world.npcs if n.id in new_ids)
143
+ assert child.country_id == "qwen"
144
+ assert child.connector_id == "qwen" # routed to the same model as the parent
145
+ assert child.unrestricted_actions == parent.unrestricted_actions
146
+ assert child.role in {"gatherer", "guard", "builder"}
147
+ # The child is registered as a citizen of its nation.
148
+ qwen = next(c for c in world.countries if c.id == "qwen")
149
+ assert child.id in qwen.citizen_ids
150
+
151
+
152
+ def test_child_of_nemotron_parent_is_nemotron_default_connector() -> None:
153
+ world = _make_two_country_world()
154
+ parent = next(n for n in world.npcs if n.country_id == "nemotron")
155
+ house = next(h for h in world.houses if h.id == "house_nemotron_001")
156
+ parent.position = house.position
157
+ parent.health = 100
158
+ parent.hunger = 0.0
159
+ parent.safety = 100.0
160
+ parent.age = 120
161
+ parent.reproduction_cooldown = 0
162
+
163
+ before = {n.id for n in world.npcs}
164
+ _maybe_reproduce(world, parent, next_tick=10)
165
+ child = next(n for n in world.npcs if n.id not in before)
166
+
167
+ assert child.country_id == "nemotron"
168
+ assert child.connector_id is None # default (Nemotron) connector