File size: 63,765 Bytes
59edb07
 
 
 
 
 
 
 
 
29d9da4
59edb07
 
4784d87
 
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
4784d87
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c082d
 
59edb07
 
 
a4397fb
 
4784d87
 
 
af7b74e
 
 
0430992
 
507f045
 
 
59edb07
 
 
0430992
 
 
 
 
 
59edb07
 
 
 
4ff38f2
 
 
59edb07
 
 
 
 
 
 
 
 
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59edb07
 
 
a4397fb
 
 
 
 
 
 
59edb07
 
 
 
 
 
0430992
59edb07
 
4784d87
 
 
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4784d87
59edb07
 
 
 
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
507f045
4784d87
 
59edb07
0430992
 
 
 
 
 
 
 
59edb07
 
0430992
59edb07
 
 
 
 
 
4784d87
 
59edb07
72938c0
 
 
 
59edb07
 
 
 
 
 
 
 
 
 
 
4784d87
 
59edb07
 
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af7b74e
507f045
af7b74e
 
59edb07
4784d87
 
 
 
 
0430992
 
 
 
 
 
 
 
 
 
af7b74e
59edb07
0430992
59edb07
 
 
 
29d9da4
 
 
af7b74e
 
 
 
 
 
 
 
 
 
 
 
 
 
766b49e
 
 
af7b74e
 
 
 
0430992
 
 
 
 
 
 
 
 
af7b74e
 
 
0430992
af7b74e
 
 
59edb07
af7b74e
59edb07
0430992
 
af7b74e
507f045
 
59edb07
 
0430992
af7b74e
 
 
 
507f045
 
 
af7b74e
0430992
 
 
 
 
 
 
 
 
af7b74e
 
 
0430992
59edb07
a4397fb
 
 
4ff38f2
 
 
72938c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4397fb
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f429d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29d9da4
 
 
da24623
59edb07
 
29d9da4
 
 
59edb07
29d9da4
 
 
 
 
59edb07
29d9da4
59edb07
 
 
 
 
 
 
29d9da4
59edb07
 
 
 
29d9da4
59edb07
 
 
 
 
 
 
 
 
 
29d9da4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59edb07
 
 
 
 
 
 
 
13e58ef
 
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c082d
 
 
 
 
72938c0
 
 
 
 
 
59edb07
 
 
 
 
da24623
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f429d3
 
 
 
 
59edb07
 
0f429d3
 
59edb07
 
 
 
 
 
 
 
 
 
 
 
 
4ff38f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4397fb
 
 
 
 
72938c0
 
a4397fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29d9da4
 
 
 
 
 
 
 
 
 
 
 
 
a4397fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ff38f2
 
a4397fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ff38f2
 
a4397fb
 
 
 
 
 
 
 
72938c0
 
 
 
 
 
 
 
 
 
a4397fb
 
 
 
72938c0
a4397fb
 
4ff38f2
72938c0
 
 
4ff38f2
 
72938c0
4ff38f2
72938c0
 
4ff38f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72938c0
4ff38f2
 
72938c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ff38f2
 
 
 
 
 
 
 
72938c0
 
4ff38f2
 
72938c0
 
 
4ff38f2
 
72938c0
4ff38f2
72938c0
4ff38f2
 
 
72938c0
4ff38f2
 
 
72938c0
4ff38f2
72938c0
4ff38f2
 
 
72938c0
4ff38f2
 
 
 
72938c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4397fb
59edb07
 
72938c0
 
59edb07
 
 
 
72938c0
59edb07
 
 
4ff38f2
a4397fb
4ff38f2
59edb07
 
 
 
 
4ff38f2
a4397fb
4ff38f2
 
 
72938c0
 
 
59edb07
 
 
 
5347dfd
 
ccac7ac
 
 
59edb07
 
 
 
 
 
 
 
 
 
 
 
ae68385
59edb07
 
 
 
 
 
 
 
 
 
 
ae68385
59edb07
 
 
4784d87
 
59edb07
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
"""Simulation — the main loop that orchestrates the entire city simulation."""

from __future__ import annotations

import asyncio
import logging
import random
from typing import Callable, Optional

from soci.agents.agent import Agent, AgentAction, AgentState
from soci.agents.memory import MemoryType
from soci.agents.persona import Persona, load_personas
from soci.agents.generator import generate_personas
from soci.agents.routine import DailyRoutine, build_routine, check_motivation_override
from soci.actions.registry import resolve_action, ACTION_NEEDS, ACTION_DURATIONS
from soci.actions.movement import execute_move
from soci.actions.activities import execute_activity
from soci.actions.conversation import (
    Conversation, initiate_conversation, continue_conversation,
)
from soci.actions.social import should_initiate_conversation, pick_conversation_partner
from soci.engine.llm import (
    ClaudeClient, MODEL_SONNET, MODEL_HAIKU,
    PLAN_DAY_PROMPT, DECIDE_ACTION_PROMPT, OBSERVE_PROMPT, REFLECT_PROMPT,
)
from soci.engine.scheduler import prioritize_agents, batch_llm_calls, should_skip_llm
from soci.engine.entropy import EntropyManager
from soci.world.city import City, generate_houses
from soci.world.clock import SimClock
from soci.world.events import EventSystem

logger = logging.getLogger(__name__)


class Simulation:
    """The main simulation engine — manages the city, agents, and time."""

    def __init__(
        self,
        city: City,
        clock: SimClock,
        llm: ClaudeClient,
        max_concurrent_llm: int = 10,
    ) -> None:
        self.city = city
        self.clock = clock
        self.llm = llm
        self.agents: dict[str, Agent] = {}
        self.events = EventSystem()
        self.entropy = EntropyManager()
        self.active_conversations: dict[str, Conversation] = {}
        self.conversation_history: list[dict] = []  # Finished conversations for API
        self._max_conversation_history: int = 50
        self._conversation_counter: int = 0
        self._max_concurrent = max_concurrent_llm
        self._tick_log: list[str] = []  # Log of events this tick
        self._event_history: list[dict] = []  # Persistent event log for API
        self._max_event_history: int = 200
        # Daily routines per agent (rebuilt from persona each day)
        self.routines: dict[str, DailyRoutine] = {}
        self._last_routine_day: int = -1
        # Speed-aware flags (set by server loop for fast-forward)
        self._skip_llm_this_tick: bool = False
        self._max_convos_this_tick: int = 0  # 0 = no limit
        self._max_llm_calls_this_tick: int = 0  # 0 = no limit; global budget across all categories
        self._llm_calls_this_tick: int = 0  # counter, reset each tick
        # LLM call probability: 0.0 = never use LLM (routine only), 1.0 = always (default).
        # Applied per potential LLM call site. Tuned at 0.45 for ~10h Gemini free-tier runtime.
        self.llm_call_probability: float = 1.0
        # Callback for real-time output
        self.on_event: Optional[Callable[[str], None]] = None

    def _llm_budget_remaining(self) -> int:
        """How many LLM calls can still be made this tick. 0 = unlimited."""
        if self._max_llm_calls_this_tick <= 0:
            return 999  # unlimited
        return max(0, self._max_llm_calls_this_tick - self._llm_calls_this_tick)

    def add_agent(self, agent: Agent) -> None:
        """Add an agent to the simulation and place them in the city."""
        self.agents[agent.id] = agent
        self.city.place_agent(agent.id, agent.location)
        # Seed biography from persona on first creation
        if not agent.life_events:
            agent.seed_biography(self.clock.day, self.clock.total_ticks)

    def load_agents_from_yaml(self, path: str) -> None:
        """Load all personas from YAML and create agents."""
        personas = load_personas(path)
        for persona in personas:
            agent = Agent(persona)
            self.add_agent(agent)
        logger.info(f"Loaded {len(personas)} agents from {path}")

    def generate_agents(self, count: int) -> None:
        """Procedurally generate `count` agents with houses and routines."""
        # Generate houses for the new agents
        house_ids = generate_houses(self.city, count)
        logger.info(f"Generated {len(house_ids)} houses for new agents")

        # Generate personas assigned to those houses
        personas = generate_personas(count, self.city)
        for persona in personas:
            agent = Agent(persona)
            self.add_agent(agent)
        logger.info(f"Generated {len(personas)} procedural agents")

    def _rebuild_routines(self) -> None:
        """Rebuild daily routines for all agents (called at start of each day)."""
        day = self.clock.day
        if self._last_routine_day == day:
            return
        self._last_routine_day = day
        for agent in self.agents.values():
            if not agent.is_player:
                self.routines[agent.id] = build_routine(agent.persona, day)
        logger.info(f"Built daily routines for {len(self.routines)} agents (day {day})")

    def _emit(self, message: str) -> None:
        """Emit an event message."""
        self._tick_log.append(message)
        self._event_history.append({
            "tick": self.clock.total_ticks,
            "time": self.clock.datetime_str,
            "message": message,
        })
        if len(self._event_history) > self._max_event_history:
            self._event_history = self._event_history[-self._max_event_history:]
        if self.on_event:
            self.on_event(message)

    async def tick(self) -> list[str]:
        """Advance the simulation by one tick. Returns list of event descriptions."""
        self._tick_log = []
        self._llm_calls_this_tick = 0  # Reset budget counter
        self._emit(f"\n--- {self.clock.datetime_str} ({self.clock.time_of_day.value}) ---")

        # 0. Rebuild routines at the start of each day (or first tick)
        self._rebuild_routines()

        # 1. Entropy management and world events
        entropy_messages = self.entropy.tick(
            list(self.agents.values()),
            self.events,
            self.clock,
            list(self.city.locations.keys()),
        )
        for msg in entropy_messages:
            self._emit(msg)

        # 2. New day — reset plans
        if self.clock.hour == 6 and self.clock.minute == 0:
            for agent in self.agents.values():
                agent.reset_daily_plan()

        # 3. Prioritize and process agents
        ordered_agents = prioritize_agents(list(self.agents.values()), self.clock)

        # 4. Daily plans — routine IS the plan, skip LLM plan generation for agents with routines
        plan_coros = []
        plan_agents = []
        for agent in ordered_agents:
            if agent.needs_new_plan(self.clock) and not should_skip_llm(agent, self.clock):
                # If agent has a routine, set a simple plan from routine slots
                if agent.id in self.routines:
                    routine = self.routines[agent.id]
                    plan_items = []
                    seen = set()
                    for slot in routine.slots:
                        label = slot.detail
                        if label not in seen:
                            plan_items.append(label)
                            seen.add(label)
                    agent.set_daily_plan(
                        plan_items[:8], self.clock.day,
                        self.clock.total_ticks, self.clock.time_str,
                    )
                elif random.random() < self.llm_call_probability:
                    plan_coros.append(self._generate_daily_plan(agent))
                    plan_agents.append(agent)

        # Cap plan coros to LLM budget
        budget = self._llm_budget_remaining()
        if budget < len(plan_coros):
            for c in plan_coros[budget:]:
                c.close()
            plan_coros = plan_coros[:budget]
            plan_agents = plan_agents[:budget]

        if plan_coros:
            await batch_llm_calls(plan_coros, self._max_concurrent)
            self._llm_calls_this_tick += len(plan_coros)
            for agent in plan_agents:
                self._emit(f"[PLAN] {agent.name} planned their day: {'; '.join(agent.daily_plan[:3])}...")

        # 5. Process each agent — tick their needs, handle actions
        action_coros = []
        action_agents = []
        routine_actions: list[tuple[Agent, AgentAction]] = []

        for agent in ordered_agents:
            # Skip dead agents and infants (under 4 — no autonomous behavior)
            if not agent.alive or agent.persona.age < 4:
                continue

            # Tick needs
            is_sleeping = agent.state.value == "sleeping"
            agent.tick_needs(is_sleeping=is_sleeping)

            # Tick current action
            if agent.is_busy:
                completed = agent.tick_action()
                if completed and agent.current_action:
                    self._emit(f"  {agent.name} finished: {agent.current_action.detail}")
                continue

            # Skip sleeping agents using per-agent awareness
            if should_skip_llm(agent, self.clock, self.routines.get(agent.id)):
                continue

            # Agent is idle — check routine first, then LLM fallback
            routine = self.routines.get(agent.id)
            if routine:
                slot = routine.get_action_for_time(self.clock.hour, self.clock.minute)
                if slot:
                    # Check if motivation overrides the routine
                    override = check_motivation_override(
                        slot, agent.needs, agent.mood,
                        agent.persona.extraversion,
                        agent.persona.conscientiousness,
                    )
                    if override:
                        slot = override
                        self._emit(f"  [MOTIVATION] {agent.name}: {override.detail}")
                    action = AgentAction(
                        type=slot.action_type,
                        target=slot.target_location,
                        detail=slot.detail,
                        duration_ticks=slot.duration_ticks,
                        needs_satisfied=slot.needs_satisfied,
                    )
                    routine_actions.append((agent, action))
                    continue

            # No routine slot — fallback to LLM (rare), skip in fast-forward
            if not self._skip_llm_this_tick and random.random() < self.llm_call_probability:
                action_coros.append(self._decide_action(agent))
                action_agents.append(agent)

        # Execute routine-driven actions (no LLM needed)
        for agent, action in routine_actions:
            await self._execute_action(agent, action)

        # Run LLM action decisions concurrently (only for agents without routine match)
        # Cap to avoid rate limits with many agents
        cap = min(
            self._max_convos_this_tick if self._max_convos_this_tick > 0 else len(action_coros),
            self._llm_budget_remaining(),
        )
        if len(action_coros) > cap:
            for c in action_coros[cap:]:
                c.close()
            action_coros = action_coros[:cap]
            action_agents = action_agents[:cap]
        if action_coros and not self._skip_llm_this_tick:
            action_results = await batch_llm_calls(action_coros, self._max_concurrent)
            self._llm_calls_this_tick += len(action_coros)
            for agent, result in zip(action_agents, action_results):
                if result and isinstance(result, AgentAction):
                    await self._execute_action(agent, result)

        # 5b. Player auto-sleep: put idle players to sleep at night
        self._handle_player_sleep()

        # 6. Handle active conversations (skip in 50x mode)
        if not self._skip_llm_this_tick:
            conv_coros = []
            for conv_id, conv in list(self.active_conversations.items()):
                if conv.is_finished:
                    self._finish_conversation(conv)
                    del self.active_conversations[conv_id]
                    continue
                # Determine who speaks next
                last_speaker = conv.turns[-1].speaker_id if conv.turns else None
                next_speaker_id = [p for p in conv.participants if p != last_speaker]
                if next_speaker_id:
                    responder = self.agents.get(next_speaker_id[0])
                    other = self.agents.get(last_speaker) if last_speaker else None
                    if responder and other:
                        # Always continue active conversations — they already passed
                        # the probability gate when initiated; don't double-gate them.
                        conv_coros.append(
                            continue_conversation(conv, responder, other, self.llm, self.clock)
                        )

            # Limit conversations by speed cap and global budget
            conv_cap = min(
                self._max_convos_this_tick if self._max_convos_this_tick > 0 else len(conv_coros),
                self._llm_budget_remaining(),
            )
            if len(conv_coros) > conv_cap:
                for c in conv_coros[conv_cap:]:
                    c.close()
                conv_coros = conv_coros[:conv_cap]

            if conv_coros:
                await batch_llm_calls(conv_coros, self._max_concurrent)
                self._llm_calls_this_tick += len(conv_coros)
        else:
            # 50x mode: force-finish all active conversations
            for conv_id, conv in list(self.active_conversations.items()):
                self._finish_conversation(conv)
            self.active_conversations.clear()

        # 7. Social: maybe start new conversations (respect speed limits + budget)
        if not self._skip_llm_this_tick and self._llm_budget_remaining() > 0:
            if self._max_convos_this_tick == 0 or len(self.active_conversations) < self._max_convos_this_tick:
                if random.random() < self.llm_call_probability:
                    await self._handle_social_interactions(ordered_agents)

        # 8. Reflections for agents with enough accumulated importance
        if not self._skip_llm_this_tick and self._llm_budget_remaining() > 0:
            reflect_coros = []
            reflect_agents = []
            for agent in ordered_agents:
                if agent.memory.should_reflect() and not agent.is_player:
                    if random.random() < self.llm_call_probability:
                        reflect_coros.append(self._generate_reflection(agent))
                        reflect_agents.append(agent)

            # Limit by speed cap and global budget
            reflect_cap = min(
                1 if self._max_convos_this_tick > 0 else len(reflect_coros),
                self._llm_budget_remaining(),
            )
            if len(reflect_coros) > reflect_cap:
                for c in reflect_coros[reflect_cap:]:
                    c.close()
                reflect_coros = reflect_coros[:reflect_cap]

            if reflect_coros:
                await batch_llm_calls(reflect_coros, self._max_concurrent)
                self._llm_calls_this_tick += len(reflect_coros)

        # 9. Romance — develop attractions and relationships
        self._tick_romance()

        # 9b. Pregnancy — check for new pregnancies and births
        self._tick_pregnancy()

        # 9c. Age progression — once per sim day at midnight
        if self.clock.hour == 0 and self.clock.minute == 0:
            self._tick_aging()

        # 9d. Divorce — check for unhappy marriages
        if self.clock.hour == 12 and self.clock.minute == 0:
            self._tick_divorce()

        # 9e. Cohabitation — married couples move in together
        if self.clock.hour == 8 and self.clock.minute == 0:
            self._tick_cohabitation()

        # 9f. Mayor election — every 365 days
        if self.clock.day > 0 and self.clock.day % 365 == 0 and self.clock.hour == 10 and self.clock.minute == 0:
            self._tick_election()

        # 9g. Short-term goal refresh — weekly
        if self.clock.day > 0 and self.clock.day % 7 == 0 and self.clock.hour == 6 and self.clock.minute == 0:
            self._tick_short_term_goals()

        # 10. Advance clock
        self.clock.tick()

        return self._tick_log

    async def _generate_daily_plan(self, agent: Agent) -> None:
        """Generate a daily plan for an agent via LLM."""
        world_desc = self.events.get_world_description()
        loc_desc = self.city.describe_location(agent.location, exclude_agent=agent.id)

        prompt = PLAN_DAY_PROMPT.format(
            time_str=self.clock.time_str,
            day=self.clock.day,
            context=agent.build_context(self.clock.total_ticks, world_desc, loc_desc),
        )

        result = await self.llm.complete_json(
            system=agent.persona.system_prompt(),
            user_message=prompt,
            model=MODEL_HAIKU,  # Plans are routine, use cheap model
            temperature=agent.persona.llm_temperature,
            max_tokens=512,
        )

        plan = result.get("plan", ["Go about my day"])
        if isinstance(plan, list):
            # Normalize: local LLMs sometimes return dicts or nested structures
            clean_plan = []
            for item in plan:
                if isinstance(item, str):
                    clean_plan.append(item)
                elif isinstance(item, dict):
                    # Try common keys: description, activity, task, text, name
                    for key in ("description", "activity", "task", "text", "name", "item"):
                        if key in item:
                            clean_plan.append(str(item[key]))
                            break
                    else:
                        clean_plan.append(str(item))
                else:
                    clean_plan.append(str(item))
            agent.set_daily_plan(clean_plan, self.clock.day, self.clock.total_ticks, self.clock.time_str)

    async def _decide_action(self, agent: Agent) -> Optional[AgentAction]:
        """Ask the LLM what action an agent should take next."""
        world_desc = self.events.get_world_description()
        loc_desc = self.city.describe_location(agent.location, exclude_agent=agent.id)

        # Get connected locations
        current_loc = self.city.get_location(agent.location)
        connected = []
        if current_loc:
            for cid in current_loc.connected_to:
                cloc = self.city.get_location(cid)
                if cloc:
                    connected.append(f"{cid} ({cloc.name})")

        # Get people at current location
        people_here = [
            self.agents[aid].name
            for aid in self.city.get_agents_at(agent.location)
            if aid != agent.id and aid in self.agents
        ]

        last_activity = "nothing in particular"
        if agent.current_action:
            last_activity = agent.current_action.detail or agent.current_action.type

        prompt = DECIDE_ACTION_PROMPT.format(
            time_str=self.clock.time_str,
            day=self.clock.day,
            context=agent.build_context(self.clock.total_ticks, world_desc, loc_desc),
            location_name=loc_desc,
            last_activity=last_activity,
            connected_locations=", ".join(connected) if connected else "none visible",
            people_here=", ".join(people_here) if people_here else "no one",
        )

        # Use Sonnet for novel situations, Haiku for routine
        model = MODEL_HAIKU
        if agent.needs.is_critical or self.events.active_events:
            model = MODEL_SONNET

        result = await self.llm.complete_json(
            system=agent.persona.system_prompt(),
            user_message=prompt,
            model=model,
            temperature=agent.persona.llm_temperature,
            max_tokens=512,
        )

        if not result:
            # Fallback: wander
            return AgentAction(type="wander", detail=f"{agent.name} wanders aimlessly")

        action = resolve_action(result, agent, self.city)
        agent._last_llm_tick = self.clock.total_ticks
        return action

    async def _execute_action(self, agent: Agent, action: AgentAction) -> None:
        """Execute an agent's chosen action."""
        if action.type == "move":
            desc = execute_move(agent, action, self.city, self.clock)
        elif action.type == "talk":
            # Talk action is handled via conversation system
            target_id = action.target
            if target_id and target_id in self.agents:
                await self._start_conversation(agent, self.agents[target_id])
                desc = f"{agent.name} starts talking to {self.agents[target_id].name}."
            else:
                desc = f"{agent.name} looks around for someone to talk to."
        else:
            desc = execute_activity(agent, action, self.city, self.clock)

        agent.start_action(action)
        self._emit(f"  {desc}")

        # Record observation
        agent.add_observation(
            tick=self.clock.total_ticks,
            day=self.clock.day,
            time_str=self.clock.time_str,
            content=desc,
            importance=3,
        )

    async def _handle_social_interactions(self, agents: list[Agent]) -> None:
        """Check if any co-located agents should start conversations."""
        # Hard cap: at most 3 simultaneous conversations
        max_convos = 3
        if len(self.active_conversations) >= max_convos:
            return

        # States where an agent can initiate or join a conversation
        _CONVERSABLE = {"idle", "eating", "relaxing", "working", "exercising", "shopping"}

        for agent in agents:
            # Skip sleeping, moving, or agents already in conversation
            if agent.state.value not in _CONVERSABLE:
                continue
            # Skip players (they initiate via the /player/talk API)
            if agent.is_player:
                continue
            # Skip if already in a conversation
            in_conv = any(
                agent.id in c.participants
                for c in self.active_conversations.values()
            )
            if in_conv:
                continue

            # Potential partners: anyone at same location who is also conversable
            others = [
                aid for aid in self.city.get_agents_at(agent.location)
                if aid != agent.id
                and aid in self.agents
                and self.agents[aid].state.value in _CONVERSABLE
                and not any(aid in c.participants for c in self.active_conversations.values())
            ]
            if not others:
                continue

            partner_id = pick_conversation_partner(agent, others, self.clock)
            if partner_id and should_initiate_conversation(agent, partner_id, self.clock):
                await self._start_conversation(agent, self.agents[partner_id])
                break  # One new conversation per tick max

    def _handle_player_sleep(self) -> None:
        """Auto-sleep idle players during sleeping hours, wake them in the morning."""
        for agent in self.agents.values():
            if not agent.is_player:
                continue
            if self.clock.is_sleeping_hours:
                if not agent.is_busy and agent.state.value != "sleeping":
                    sleep_action = AgentAction(
                        type="sleep",
                        target=agent.persona.home_location,
                        detail=f"{agent.name} goes to sleep for the night",
                        duration_ticks=32,  # ~8 hours
                        needs_satisfied={"energy": 0.9},
                    )
                    # Move home if needed (teleport — player is asleep)
                    home = agent.persona.home_location
                    if agent.location != home and self.city.get_location(home):
                        old_loc = self.city.get_location(agent.location)
                        new_loc = self.city.get_location(home)
                        if old_loc:
                            old_loc.remove_occupant(agent.id)
                        new_loc.add_occupant(agent.id)
                        agent.location = home
                    agent.start_action(sleep_action)
                    self._emit(f"  {agent.name} goes to sleep for the night.")
            else:
                # Wake player if still sleeping past sleeping hours
                if agent.state.value == "sleeping":
                    agent.state = AgentState.IDLE
                    agent.current_action = None
                    agent._action_ticks_remaining = 0

    async def _start_conversation(self, initiator: Agent, target: Agent) -> None:
        """Start a conversation between two agents."""
        self._conversation_counter += 1
        conv_id = f"conv_{self._conversation_counter}"

        conv = await initiate_conversation(
            initiator, target, self.llm, self.clock, conv_id,
        )
        if conv is None:
            return  # LLM unavailable — skip this conversation
        self.active_conversations[conv_id] = conv

        # Both agents are now in conversation
        from soci.agents.agent import AgentAction
        talk_action = AgentAction(
            type="talk",
            target=target.id,
            detail=f"talking to {target.name}",
            duration_ticks=conv.max_turns,
            needs_satisfied={"social": 0.3},
        )
        initiator.start_action(talk_action)

        talk_action_target = AgentAction(
            type="talk",
            target=initiator.id,
            detail=f"talking to {initiator.name}",
            duration_ticks=conv.max_turns,
            needs_satisfied={"social": 0.3},
        )
        target.start_action(talk_action_target)

        self._emit(f"  [CONV] {initiator.name} starts talking to {target.name}")

        # Both agents observe the conversation start
        for agent, other in [(initiator, target), (target, initiator)]:
            agent.add_observation(
                tick=self.clock.total_ticks,
                day=self.clock.day,
                time_str=self.clock.time_str,
                content=f"Started a conversation with {other.name} at {agent.location}",
                importance=5,
                involved_agents=[other.id],
            )
            # Ensure relationship exists
            agent.relationships.get_or_create(other.id, other.name)

    def _finish_conversation(self, conv: Conversation) -> None:
        """Record a finished conversation in both agents' memories."""
        if len(conv.turns) < 2:
            return

        summary = f"Had a conversation about '{conv.topic}' with "
        for agent_id in conv.participants:
            agent = self.agents.get(agent_id)
            if not agent:
                continue
            other_ids = [p for p in conv.participants if p != agent_id]
            other_names = [self.agents[oid].name for oid in other_ids if oid in self.agents]
            agent.memory.add(
                tick=self.clock.total_ticks,
                day=self.clock.day,
                time_str=self.clock.time_str,
                memory_type=MemoryType.CONVERSATION,
                content=f"Had a conversation about '{conv.topic}' with {', '.join(other_names)}.",
                importance=6,
                location=conv.location,
                involved_agents=other_ids,
            )

        # Store in conversation history for API
        self.conversation_history.append(conv.to_dict())
        if len(self.conversation_history) > self._max_conversation_history:
            self.conversation_history = self.conversation_history[-self._max_conversation_history:]

        # Boost community score for social interaction
        for agent_id in conv.participants:
            agent = self.agents.get(agent_id)
            if agent and agent.alive:
                agent.community_score += 0.5

        self._emit(
            f"  [CONV END] Conversation about '{conv.topic}' between "
            f"{', '.join(self.agents[p].name for p in conv.participants if p in self.agents)} ended."
        )

        # Gossip: chance of mentioning a third person both know
        self._maybe_gossip(conv)

    def _maybe_gossip(self, conv: Conversation) -> None:
        """After a conversation, participants might share info about a third person."""
        if len(conv.participants) < 2 or random.random() > 0.35:
            return

        from soci.actions.social import propagate_gossip

        p1 = self.agents.get(conv.participants[0])
        p2 = self.agents.get(conv.participants[1])
        if not p1 or not p2:
            return

        # Find a third person both know
        p1_known = {r.agent_id for r in p1.relationships.get_closest(10) if r.familiarity > 0.2}
        p2_known = {r.agent_id for r in p2.relationships.get_closest(10) if r.familiarity > 0.2}
        mutual = (p1_known & p2_known) - {p1.id, p2.id}

        if not mutual:
            return

        about_id = random.choice(list(mutual))
        about = self.agents.get(about_id)
        if not about:
            return

        # Speaker shares their impression
        speaker, listener = (p1, p2) if random.random() < 0.5 else (p2, p1)
        rel = speaker.relationships.get(about_id)
        if not rel:
            return

        # Generate gossip note based on sentiment
        if rel.sentiment > 0.7:
            note = f"{about.name} is really great, always so helpful"
        elif rel.sentiment < 0.3:
            note = f"I've had some issues with {about.name} lately"
        else:
            note = f"I ran into {about.name} the other day"

        propagate_gossip(speaker, listener, about_id, about.name, note, self.clock.total_ticks)
        self._emit(f"  [GOSSIP] {speaker.name} told {listener.name} about {about.name}")

    async def _generate_reflection(self, agent: Agent) -> None:
        """Generate a reflection for an agent about recent experiences."""
        recent = agent.memory.get_recent(15)
        recent_text = "\n".join(
            f"- [{m.time_str}] {m.content}" for m in recent
        )

        world_desc = self.events.get_world_description()
        loc_desc = self.city.describe_location(agent.location, exclude_agent=agent.id)

        prompt = REFLECT_PROMPT.format(
            time_str=self.clock.time_str,
            day=self.clock.day,
            context=agent.build_context(self.clock.total_ticks, world_desc, loc_desc),
            recent_memories=recent_text,
        )

        result = await self.llm.complete_json(
            system=agent.persona.system_prompt(),
            user_message=prompt,
            model=MODEL_HAIKU,
            temperature=agent.persona.llm_temperature,
            max_tokens=512,
        )

        reflections = result.get("reflections", [])
        mood_shift = result.get("mood_shift", 0.0)
        # Clamp mood_shift to valid range
        try:
            mood_shift = max(-0.3, min(0.3, float(mood_shift)))
        except (TypeError, ValueError):
            mood_shift = 0.0

        for ref_text in reflections:
            if not isinstance(ref_text, str):
                ref_text = str(ref_text)
            agent.add_reflection(
                tick=self.clock.total_ticks,
                day=self.clock.day,
                time_str=self.clock.time_str,
                content=ref_text,
            )

        agent.mood = max(-1.0, min(1.0, agent.mood + mood_shift))
        agent.memory.reset_reflection_accumulator()

        if reflections:
            self._emit(f"  [REFLECT] {agent.name}: {reflections[0]}")

        # Life event from reflection (optional — LLM may return null)
        life_event = result.get("life_event")
        if life_event and isinstance(life_event, dict):
            evt_type = life_event.get("type", "milestone")
            evt_desc = life_event.get("description", "")
            if evt_desc:
                agent.add_life_event(self.clock.day, self.clock.total_ticks, evt_type, evt_desc)
                agent.add_observation(
                    tick=self.clock.total_ticks, day=self.clock.day,
                    time_str=self.clock.time_str,
                    content=f"Life milestone: {evt_desc}", importance=9,
                )
                self._emit(f"  [LIFE] {agent.name}: {evt_desc}")

        # Goal updates from reflection (optional)
        goal_update = result.get("goal_update")
        if goal_update and isinstance(goal_update, dict):
            action = goal_update.get("action", "")
            if action == "add" and goal_update.get("description"):
                agent.add_goal(goal_update["description"])
                self._emit(f"  [GOAL] {agent.name} new goal: {goal_update['description']}")
            elif action == "complete" and goal_update.get("goal_id") is not None:
                try:
                    gid = int(goal_update["goal_id"])
                    agent.update_goal(gid, status="completed", progress=1.0)
                    self._emit(f"  [GOAL] {agent.name} completed a goal!")
                except (TypeError, ValueError):
                    pass
            elif action == "progress" and goal_update.get("goal_id") is not None:
                try:
                    gid = int(goal_update["goal_id"])
                    prog = float(goal_update.get("progress", 0.5))
                    agent.update_goal(gid, progress=prog)
                except (TypeError, ValueError):
                    pass

    def _tick_romance(self) -> None:
        """Develop romantic attractions between compatible agents at the same location."""
        agents_list = list(self.agents.values())

        for agent in agents_list:
            if not agent.alive or agent.persona.age < 16:
                continue
            loc = self.city.get_location(agent.location)
            if not loc:
                continue

            for other_id in loc.occupants:
                if other_id == agent.id or other_id not in self.agents:
                    continue
                other = self.agents[other_id]

                rel = agent.relationships.get(other_id)
                if not rel or rel.familiarity < 0.15:
                    continue  # Need to know someone a bit first

                # Skip if already married to someone else
                if agent.partner_id and agent.partner_id != other_id:
                    continue

                # Gender-based attraction: opposite genders attract (nonbinary attracted to all)
                a_gender = agent.persona.gender
                o_gender = other.persona.gender
                attracted = (
                    a_gender == "unknown"
                    or a_gender == "nonbinary"
                    or o_gender == "unknown"
                    or o_gender == "nonbinary"
                    or a_gender != o_gender
                )
                if not attracted:
                    continue

                # Attraction grows from positive interactions
                if rel.sentiment > 0.6 and rel.trust > 0.5:
                    # Base attraction growth per tick
                    growth = 0.008
                    # Boost from high agreeableness and extraversion
                    growth += (agent.persona.agreeableness / 100.0) * 0.005
                    # Boost from familiarity
                    growth += rel.familiarity * 0.005
                    # Boost when both at same location and interacting
                    if agent.state.value == "in_conversation":
                        growth += 0.01

                    rel.romantic_interest = min(1.0, rel.romantic_interest + growth)

                # Relationship status progression (no LLM calls — pure rules)
                ri = rel.romantic_interest
                status = rel.relationship_status

                if status == "none" and ri > 0.25:
                    rel.relationship_status = "crushing"
                    self._emit(f"  [ROMANCE] {agent.name} has developed a crush on {other.name}!")
                    agent.add_observation(
                        tick=self.clock.total_ticks, day=self.clock.day,
                        time_str=self.clock.time_str,
                        content=f"I think I'm developing feelings for {other.name}...",
                        importance=7, involved_agents=[other_id],
                    )

                elif status == "crushing" and ri > 0.5 and rel.familiarity > 0.4:
                    # Check if the other person also has feelings
                    other_rel = other.relationships.get(agent.id)
                    if other_rel and other_rel.romantic_interest > 0.3:
                        rel.relationship_status = "dating"
                        other_rel.relationship_status = "dating"
                        agent.partner_id = other_id
                        other.partner_id = agent.id
                        self._emit(f"  [ROMANCE] {agent.name} and {other.name} have started dating!")
                        for a, o in [(agent, other), (other, agent)]:
                            a.add_observation(
                                tick=self.clock.total_ticks, day=self.clock.day,
                                time_str=self.clock.time_str,
                                content=f"I'm now dating {o.name}! I feel excited and nervous.",
                                importance=9, involved_agents=[o.id],
                            )
                            a.add_life_event(self.clock.day, self.clock.total_ticks,
                                             "dating", f"Started dating {o.name}")
                        agent.mood = min(1.0, agent.mood + 0.3)
                        other.mood = min(1.0, other.mood + 0.3)

                elif status == "dating" and ri > 0.75 and rel.familiarity > 0.7:
                    other_rel = other.relationships.get(agent.id)
                    if other_rel and other_rel.romantic_interest > 0.65:
                        rel.relationship_status = "engaged"
                        other_rel.relationship_status = "engaged"
                        self._emit(f"  [ROMANCE] {agent.name} and {other.name} got engaged!")
                        for a, o in [(agent, other), (other, agent)]:
                            a.add_observation(
                                tick=self.clock.total_ticks, day=self.clock.day,
                                time_str=self.clock.time_str,
                                content=f"{o.name} and I are engaged! This is the happiest day of my life.",
                                importance=10, involved_agents=[o.id],
                            )
                            a.add_life_event(self.clock.day, self.clock.total_ticks,
                                             "engaged", f"Got engaged to {o.name}")
                        agent.mood = min(1.0, agent.mood + 0.4)
                        other.mood = min(1.0, other.mood + 0.4)

                elif status == "engaged" and ri > 0.9 and rel.interaction_count > 15:
                    other_rel = other.relationships.get(agent.id)
                    if other_rel and other_rel.romantic_interest > 0.8:
                        rel.relationship_status = "married"
                        other_rel.relationship_status = "married"
                        # Move both to church for the ceremony
                        church = self.city.get_location("church")
                        if church:
                            for a in [agent, other]:
                                old_loc = self.city.get_location(a.location)
                                if old_loc and a.location != "church":
                                    old_loc.remove_occupant(a.id)
                                    church.add_occupant(a.id)
                                    a.location = "church"
                        self._emit(f"  [ROMANCE] {agent.name} and {other.name} got married at the church!")
                        for a, o in [(agent, other), (other, agent)]:
                            a.add_observation(
                                tick=self.clock.total_ticks, day=self.clock.day,
                                time_str=self.clock.time_str,
                                content=f"I married {o.name} today at the church. I couldn't be happier.",
                                importance=10, involved_agents=[o.id],
                            )
                            a.add_life_event(self.clock.day, self.clock.total_ticks,
                                             "married", f"Married {o.name} at St. Mary's Church")
                            # Boost community score for getting married
                            a.community_score += 5.0

    def _tick_pregnancy(self) -> None:
        """Handle pregnancy for married couples. Children are born at the hospital after ~9 sim-months."""
        import random as _rand
        # 9 sim-months: 9 * 30 days * 96 ticks/day = 25920, but compressed to ~9 sim-days
        PREGNANCY_DURATION_TICKS = 864  # ~9 days (96 ticks/day × 9 days)

        for agent in list(self.agents.values()):
            # New pregnancy chance: married female, at home with partner
            if (agent.persona.gender == "female"
                    and not agent.pregnant
                    and agent.partner_id
                    and agent.partner_id in self.agents
                    and len(agent.children) < 3):  # max 3 children
                partner = self.agents[agent.partner_id]
                rel = agent.relationships.get(partner.id)
                if (rel and rel.relationship_status == "married"
                        and agent.location == partner.location
                        and agent.location == agent.persona.home_location
                        and _rand.random() < 0.002):
                    agent.pregnant = True
                    agent.pregnancy_start_tick = self.clock.total_ticks
                    agent.pregnancy_partner_id = partner.id
                    agent.add_life_event(self.clock.day, self.clock.total_ticks,
                                         "pregnant", f"Expecting a baby with {partner.name}!")
                    partner.add_life_event(self.clock.day, self.clock.total_ticks,
                                           "pregnant", f"{agent.name} and I are expecting a baby!")
                    for a in (agent, partner):
                        a.add_observation(
                            tick=self.clock.total_ticks, day=self.clock.day,
                            time_str=self.clock.time_str,
                            content=f"We're going to have a baby!",
                            importance=10, involved_agents=[partner.id if a == agent else agent.id],
                        )
                        a.mood = min(1.0, a.mood + 0.4)
                    self._emit(f"  [LIFE] {agent.name} and {partner.name} are expecting!")

            # Move to hospital when close to due date (~1 day before)
            if agent.pregnant:
                elapsed = self.clock.total_ticks - agent.pregnancy_start_tick
                if elapsed >= (PREGNANCY_DURATION_TICKS - 96) and agent.location != "hospital":
                    hospital = self.city.get_location("hospital")
                    if hospital:
                        old_loc = self.city.get_location(agent.location)
                        if old_loc:
                            old_loc.remove_occupant(agent.id)
                        hospital.add_occupant(agent.id)
                        agent.location = "hospital"
                        agent.add_observation(
                            tick=self.clock.total_ticks, day=self.clock.day,
                            time_str=self.clock.time_str,
                            content="Going to the hospital — the baby is coming soon!",
                            importance=8,
                        )
                        self._emit(f"  [LIFE] {agent.name} went to the hospital for delivery!")

                if elapsed >= PREGNANCY_DURATION_TICKS:
                    partner = self.agents.get(agent.pregnancy_partner_id)
                    # Pick a baby name
                    import random as _r
                    baby_names_m = ["Oliver", "Liam", "Noah", "Elias", "Lucas", "Theo", "Leo", "Max"]
                    baby_names_f = ["Emma", "Olivia", "Sophia", "Mia", "Isabella", "Zoe", "Luna", "Aria"]
                    is_girl = _r.random() < 0.5
                    pool = baby_names_f if is_girl else baby_names_m
                    # Avoid duplicate names across all agents
                    used = set(agent.children) | {a.name.split()[-1] for a in self.agents.values()}
                    available = [n for n in pool if n not in used]
                    baby_name = _r.choice(available) if available else _r.choice(pool)
                    # Surname from mother
                    surname = agent.name.split()[-1] if " " in agent.name else ""
                    full_baby_name = f"{baby_name} {surname}".strip()

                    agent.pregnant = False
                    agent.children.append(full_baby_name)
                    agent.add_life_event(self.clock.day, self.clock.total_ticks,
                                         "child_born", f"Gave birth to {full_baby_name}!")
                    agent.add_observation(
                        tick=self.clock.total_ticks, day=self.clock.day,
                        time_str=self.clock.time_str,
                        content=f"Our baby {full_baby_name} was born today! I'm overwhelmed with joy.",
                        importance=10,
                    )
                    if partner:
                        partner.children.append(full_baby_name)
                        partner.add_life_event(self.clock.day, self.clock.total_ticks,
                                               "child_born", f"{agent.name} and I welcomed {full_baby_name}!")
                        partner.add_observation(
                            tick=self.clock.total_ticks, day=self.clock.day,
                            time_str=self.clock.time_str,
                            content=f"Our baby {full_baby_name} was born! I'm a parent now!",
                            importance=10,
                        )
                        partner.mood = min(1.0, partner.mood + 0.5)
                    agent.mood = min(1.0, agent.mood + 0.5)
                    self._emit(f"  [LIFE] {agent.name} gave birth to {full_baby_name}!")

                    # Create actual baby agent living with parents
                    baby_id = f"baby_{full_baby_name.lower().replace(' ', '_')}_{self.clock.total_ticks}"
                    baby_gender = "female" if is_girl else "male"
                    baby_persona = Persona(
                        id=baby_id,
                        name=full_baby_name,
                        age=0,
                        occupation="infant",
                        gender=baby_gender,
                        background=f"Born to {agent.name}" + (f" and {partner.name}" if partner else "") + " in Soci City.",
                        home_location=agent.persona.home_location,
                        work_location="",
                        openness=_r.randint(3, 8),
                        conscientiousness=_r.randint(3, 8),
                        extraversion=_r.randint(3, 8),
                        agreeableness=_r.randint(4, 9),
                        neuroticism=_r.randint(2, 7),
                    )
                    baby_agent = Agent(baby_persona)
                    baby_agent._birth_day = self.clock.day
                    baby_agent._birth_age = 0
                    baby_agent.parent_ids = [agent.id] + ([partner.id] if partner else [])
                    baby_agent.lifecycle_stage = "infant"
                    # Baby lives at parents' home, no memories until age 4
                    self.add_agent(baby_agent)
                    self._emit(f"  [LIFE] New citizen: {full_baby_name} born!")

    def _tick_aging(self) -> None:
        """Update ages for all agents. Check for death of elderly agents."""
        for agent in list(self.agents.values()):
            if not agent.alive:
                continue
            old_age = agent.persona.age
            changed = agent.tick_age(self.clock.day)
            if changed:
                new_age = agent.persona.age
                # Birthday event
                agent.add_life_event(
                    self.clock.day, self.clock.total_ticks,
                    "birthday", f"Turned {new_age} years old",
                )
                agent.add_observation(
                    tick=self.clock.total_ticks, day=self.clock.day,
                    time_str=self.clock.time_str,
                    content=f"Today is my birthday! I'm now {new_age} years old.",
                    importance=6,
                )
                self._emit(f"  [LIFE] {agent.name} turned {new_age}!")

                # Lifecycle stage transitions with goal updates
                if new_age == 4:
                    agent.add_goal("Go to kindergarten", term="long")
                    self._emit(f"  [LIFE] {agent.name} is old enough for kindergarten!")
                elif new_age == 6:
                    agent.add_goal("Do well in school", term="long")
                elif new_age == 12:
                    agent.add_goal("Graduate high school", term="long")
                elif new_age == 18:
                    agent.add_goal("Go to university", term="long")
                    agent.add_goal("Get a job", term="long")
                elif new_age == 22:
                    agent.add_goal("Start a career", term="long")

            # Death check for elderly
            if agent.persona.age >= 80 and agent.check_death(self.clock.day):
                agent.die(self.clock.day, self.clock.total_ticks)
                self._emit(f"  [DEATH] {agent.name} passed away at age {agent.persona.age}.")
                # Notify partner
                if agent.partner_id and agent.partner_id in self.agents:
                    partner = self.agents[agent.partner_id]
                    partner.partner_id = None
                    partner.mood = max(-1.0, partner.mood - 0.6)
                    partner.add_life_event(
                        self.clock.day, self.clock.total_ticks,
                        "bereavement", f"Lost my partner {agent.name}",
                    )
                    partner.add_observation(
                        tick=self.clock.total_ticks, day=self.clock.day,
                        time_str=self.clock.time_str,
                        content=f"My partner {agent.name} passed away. I'm devastated.",
                        importance=10, involved_agents=[agent.id],
                    )
                # Remove from city location
                loc = self.city.get_location(agent.location)
                if loc:
                    loc.remove_occupant(agent.id)

    def _tick_divorce(self) -> None:
        """Check for unhappy marriages that lead to divorce."""
        for agent in list(self.agents.values()):
            if not agent.alive or not agent.partner_id:
                continue
            partner = self.agents.get(agent.partner_id)
            if not partner or not partner.alive:
                continue
            rel = agent.relationships.get(partner.id)
            if not rel or rel.relationship_status != "married":
                continue
            # Divorce conditions: very low sentiment + trust + mood for extended period
            if (rel.sentiment < 0.2 and rel.trust < 0.25
                    and agent.mood < -0.3
                    and random.random() < 0.01):  # 1% daily chance when unhappy
                # Divorce!
                rel.relationship_status = "divorced"
                rel.romantic_interest = max(0, rel.romantic_interest - 0.5)
                other_rel = partner.relationships.get(agent.id)
                if other_rel:
                    other_rel.relationship_status = "divorced"
                    other_rel.romantic_interest = max(0, other_rel.romantic_interest - 0.5)
                agent.partner_id = None
                partner.partner_id = None
                for a, o in [(agent, partner), (partner, agent)]:
                    a.add_life_event(
                        self.clock.day, self.clock.total_ticks,
                        "divorce", f"Divorced {o.name}",
                    )
                    a.add_observation(
                        tick=self.clock.total_ticks, day=self.clock.day,
                        time_str=self.clock.time_str,
                        content=f"I divorced {o.name}. Our marriage wasn't working anymore.",
                        importance=9, involved_agents=[o.id],
                    )
                    a.mood = max(-1.0, a.mood - 0.4)
                self._emit(f"  [ROMANCE] {agent.name} and {partner.name} got divorced!")

    def _tick_cohabitation(self) -> None:
        """Married couples move in together — move to one partner's home."""
        processed = set()
        for agent in list(self.agents.values()):
            if not agent.alive or not agent.partner_id or agent.id in processed:
                continue
            partner = self.agents.get(agent.partner_id)
            if not partner or not partner.alive or partner.id in processed:
                continue
            rel = agent.relationships.get(partner.id)
            if not rel or rel.relationship_status != "married":
                continue
            # Already living together?
            if agent.persona.home_location == partner.persona.home_location:
                processed.add(agent.id)
                processed.add(partner.id)
                continue
            # Move to the home of the agent who has lived there longer (or whichever is non-empty)
            new_home = agent.persona.home_location
            mover = partner
            partner.persona.home_location = new_home
            mover.add_life_event(
                self.clock.day, self.clock.total_ticks,
                "moved", f"Moved in with {agent.name} at {new_home}",
            )
            agent.add_observation(
                tick=self.clock.total_ticks, day=self.clock.day,
                time_str=self.clock.time_str,
                content=f"{partner.name} moved in with me!",
                importance=8, involved_agents=[partner.id],
            )
            self._emit(f"  [LIFE] {partner.name} moved in with {agent.name}")
            processed.add(agent.id)
            processed.add(partner.id)

    def _tick_election(self) -> None:
        """Annual mayor election — all citizens 18+ vote for the most community-oriented agent."""
        eligible_voters = [
            a for a in self.agents.values()
            if a.alive and a.persona.age >= 18
        ]
        if len(eligible_voters) < 3:
            return

        # Candidates: all eligible non-player agents
        candidates = [a for a in eligible_voters if not a.is_player]
        if not candidates:
            return

        # Score candidates by community contribution:
        # - interaction_count with diverse agents
        # - agreeableness trait
        # - community_score (accumulated from actions)
        # - mood (positive people inspire)
        scores: dict[str, float] = {}
        for c in candidates:
            score = 0.0
            # Social connections
            known = c.relationships.get_closest(20)
            score += len(known) * 2.0
            score += sum(r.sentiment for r in known) * 3.0
            # Personality
            score += c.persona.agreeableness * 1.5
            score += c.persona.extraversion * 0.5
            # Mood and contribution
            score += max(0, c.mood) * 5.0
            score += c.community_score
            scores[c.id] = score

        # Each voter votes — weighted random choice biased toward high scores
        votes: dict[str, int] = {c.id: 0 for c in candidates}
        for voter in eligible_voters:
            # Bias toward people the voter knows and likes
            voter_scores = {}
            for c in candidates:
                if c.id == voter.id:
                    continue
                base = scores.get(c.id, 0)
                rel = voter.relationships.get(c.id)
                if rel:
                    base += rel.sentiment * 10 + rel.trust * 5
                voter_scores[c.id] = max(0.1, base)
            if not voter_scores:
                continue
            total = sum(voter_scores.values())
            r = random.random() * total
            cumulative = 0.0
            chosen = list(voter_scores.keys())[0]
            for cid, s in voter_scores.items():
                cumulative += s
                if r <= cumulative:
                    chosen = cid
                    break
            votes[chosen] = votes.get(chosen, 0) + 1

        # Winner
        winner_id = max(votes, key=lambda k: votes[k])
        winner = self.agents[winner_id]
        vote_count = votes[winner_id]

        # Remove old mayor
        for a in self.agents.values():
            if a.is_mayor:
                a.is_mayor = False
                a.add_life_event(
                    self.clock.day, self.clock.total_ticks,
                    "politics", "Term as Mayor ended",
                )

        # Set new mayor
        winner.is_mayor = True
        winner.mayor_term_start_day = self.clock.day
        winner.add_life_event(
            self.clock.day, self.clock.total_ticks,
            "politics", f"Elected as Mayor of Soci City with {vote_count} votes!",
        )
        winner.add_observation(
            tick=self.clock.total_ticks, day=self.clock.day,
            time_str=self.clock.time_str,
            content=f"I was elected Mayor of Soci City! {vote_count} people voted for me.",
            importance=10,
        )
        winner.mood = min(1.0, winner.mood + 0.5)
        self._emit(f"  [ELECTION] {winner.name} elected Mayor with {vote_count}/{len(eligible_voters)} votes!")

        # All voters remember the election
        for voter in eligible_voters:
            if voter.id != winner_id:
                voter.add_observation(
                    tick=self.clock.total_ticks, day=self.clock.day,
                    time_str=self.clock.time_str,
                    content=f"{winner.name} was elected as our new Mayor.",
                    importance=6, involved_agents=[winner_id],
                )

    def _tick_short_term_goals(self) -> None:
        """Refresh short-term goals weekly — expire old ones, add new ones."""
        import random as _r
        short_goals_pool = [
            "Meet someone new this week",
            "Try a new restaurant",
            "Exercise at least twice",
            "Read something interesting",
            "Help a neighbor",
            "Visit the park",
            "Have a deep conversation",
            "Cook something special",
            "Explore a new part of town",
            "Catch up with an old friend",
            "Organize my home",
            "Learn something new",
            "Spend time outdoors",
            "Do something creative",
        ]
        for agent in self.agents.values():
            if not agent.alive or agent.persona.age < 6:
                continue
            # Expire active short-term goals
            for g in agent.goals:
                if g.get("term") == "short" and g["status"] == "active":
                    if g["progress"] >= 0.7:
                        g["status"] = "completed"
                        g["progress"] = 1.0
                    else:
                        g["status"] = "abandoned"
            # Add 1-2 new short-term goals
            count = _r.randint(1, 2)
            for _ in range(count):
                goal_desc = _r.choice(short_goals_pool)
                agent.add_goal(goal_desc, term="short")

    def get_state_summary(self) -> dict:
        """Get a summary of the current simulation state."""
        # Find current mayor
        mayor = next((a for a in self.agents.values() if a.is_mayor and a.alive), None)
        return {
            "clock": self.clock.to_dict(),
            "weather": self.events.weather.value,
            "active_events": [e.to_dict() for e in self.events.active_events],
            "mayor": {"id": mayor.id, "name": mayor.name} if mayor else None,
            "agents": {
                aid: {
                    "name": a.name,
                    "age": a.persona.age,
                    "gender": a.persona.gender,
                    "occupation": a.persona.occupation,
                    "location": a.location,
                    "state": a.state.value,
                    "mood": round(a.mood, 2),
                    "needs": a.needs.to_dict(),
                    "action": a.current_action.detail if a.current_action else "idle",
                    "daily_plan": a.daily_plan,
                    "partner_id": a.partner_id,
                    "is_player": a.is_player,
                    "pregnant": a.pregnant,
                    "children_count": len(a.children),
                    "alive": a.alive,
                    "lifecycle_stage": a.lifecycle_stage,
                    "is_mayor": a.is_mayor,
                }
                for aid, a in self.agents.items()
            },
            "active_conversations": len(self.active_conversations),
            "llm_provider": getattr(self.llm, "provider", "unknown"),
            "llm_model": getattr(self.llm, "default_model", "unknown"),
            "llm_status": getattr(self.llm, "llm_status", "active"),
            "llm_calls_last_tick": self._llm_calls_this_tick,
            "llm_skipped": self._skip_llm_this_tick,
            "llm_usage": self.llm.usage.summary(),
        }

    def to_dict(self) -> dict:
        """Serialize full simulation state."""
        return {
            "city": self.city.to_dict(),
            "clock": self.clock.to_dict(),
            "agents": {aid: a.to_dict() for aid, a in self.agents.items()},
            "events": self.events.to_dict(),
            "entropy": self.entropy.to_dict(),
            "conversation_counter": self._conversation_counter,
            "conversation_history": self.conversation_history,
        }

    @classmethod
    def from_dict(cls, data: dict, llm: ClaudeClient) -> Simulation:
        """Restore a simulation from serialized state."""
        city = City.from_dict(data["city"])
        clock = SimClock.from_dict(data["clock"])
        sim = cls(city=city, clock=clock, llm=llm)
        sim.events = EventSystem.from_dict(data["events"])
        sim.entropy = EntropyManager.from_dict(data["entropy"])
        sim._conversation_counter = data.get("conversation_counter", 0)
        sim.conversation_history = data.get("conversation_history", [])
        for aid, agent_data in data["agents"].items():
            agent = Agent.from_dict(agent_data)
            sim.agents[agent.id] = agent
        # Rebuild routines from restored personas (deterministic)
        sim._rebuild_routines()
        return sim