-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsimulation.py
More file actions
2631 lines (2224 loc) · 111 KB
/
Copy pathsimulation.py
File metadata and controls
2631 lines (2224 loc) · 111 KB
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
"""
Predator-Prey Simulation
Designed for client-server architecture:
- Agents have stable unique IDs
- Spatial indexing via grid cells for O(1) neighbor queries
- Dirty tracking for delta updates
- Clean separation of state vs logic
"""
import random
import math
import numpy as np
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, Set, List, Tuple, Optional
from collections import defaultdict
import logging
TWOPI = 2 * math.pi
class AgentType(Enum):
PREY = 0
PREDATOR = 1
@dataclass
class Agent:
"""Minimal agent state - everything needed to render + simulate"""
id: int
agent_type: AgentType
x: float # Float position (cell = int(x), int(y))
y: float
energy: float
sex: int = 0 # 0 = male, 1 = female
born: int = 0 # Tick when agent was spawned
parent: List[int] = field(default_factory=lambda: [-1, -1]) # IDs of parents ([-1,-1] for founders)
offspring: List[int] = field(default_factory=list) # IDs of offspring
# Genetic parameters (inherited with mutation)
vision: float = 0.0 # Effective vision range (capped by type max)
brutpflege: float = 0.0 # Maternal investment (extra energy to offspring)
speed: float = 0.0 # Sprint speed modifier (added to sprint ratio)
heat: float = 0.0 # Mating priority exponent (predators only), 'heat' misnomer, as lower = more mating focus
aggression: float = 0.0 # Base attack probability (prey only for now)
genes: List[float] = field(default_factory=lambda: [5.0, 5.0, 5.0]) # Speciation genes [0-10]
# Movement state
bearing: float = 0.0 # Current heading in radians [0, 2π)
@property
def cell(self) -> Tuple[int, int]:
"""Current cell for food/interactions (floor of position)"""
return (int(self.x), int(self.y))
# Class-level definition of phenotype fields (sent in streaming)
# Everything else is "internal" (only sent on inspect)
PHENOTYPE_FIELDS = ('id', 'type', 'x', 'y')
@property
def position(self) -> tuple:
"""
Agent's position in the simulation's coordinate system.
Returns an opaque position tuple - the server should not
interpret the contents, only pass them through.
For this 2D simulation, returns (x, y).
"""
return (self.x, self.y)
def to_display_dict(self) -> dict:
"""
Minimal data for visualization - streamed continuously.
Only includes phenotype fields for efficient bandwidth.
Positions rounded to 2 decimal places.
"""
return {
'id': self.id,
'type': self.agent_type.value,
'sex': self.sex,
'x': round(self.x, 2),
'y': round(self.y, 2),
}
def to_viewport_dict(self) -> dict:
"""
Intermediate data for agents inviewport.
Positions rounded to 2 decimal places.
"""
return {
'id': self.id,
'type': self.agent_type.value,
'sex': self.sex,
'x': round(self.x, 2),
'y': round(self.y, 2),
'e': round(self.energy, 2),
'gn': [round(g, 2) for g in self.genes],
}
def to_full_dict(self) -> dict:
"""
Complete state - only sent on inspect request.
Includes phenotype + internal state + genetics.
Positions rounded to 2 decimal places, bearing (in radians) to 3 places.
name conventions to save bandwidth (but imposes some load on client to translate back)
const renameMap = {
a: 'bearing',
e: 'energy',
b: 'born',
p: 'parent',
o: 'offspring',
v: 'vision',
pf: 'brutpflege',
s: 'speed',
h: 'heat',
ag: 'aggression'
gn: 'genes',
};
"""
return {
'id': self.id,
'type': self.agent_type.value,
'sex': self.sex,
'x': round(self.x, 2),
'y': round(self.y, 2),
#above: streamed values, below: "internal state" only sent on request
'a': round(self.bearing, 3),
'e': round(self.energy, 2),
'b': self.born,
'p': self.parent.copy(), # Copy to avoid mutation
'o': self.offspring.copy(), # Copy to avoid mutation
'v': self.vision,
'pf': self.brutpflege,
's': self.speed,
'h': self.heat,
'ag': self.aggression,
'gn': [round(g, 2) for g in self.genes],
}
def to_dict(self) -> dict:
"""Serializable representation (full state) - backward compatible"""
return self.to_full_dict()
class SpatialGrid:
"""
Spatial index for O(1) queries of "which agents are in cell (x,y)?"
Critical for scalability: without this, finding nearby agents is O(n).
With this, it's O(agents_in_cell).
Accepts float positions - converts to int cell coordinates internally.
Maintains separate indices by agent type for efficient type-specific queries.
"""
def __init__(self, width: int, height: int):
self.width = width
self.height = height
# cell -> set of agent IDs in that cell (all types)
self._grid: Dict[Tuple[int, int], Set[int]] = defaultdict(set)
# Type-specific grids: type -> cell -> set of agent IDs
self._type_grids: Dict[int, Dict[Tuple[int, int], Set[int]]] = {
0: defaultdict(set), # PREY
1: defaultdict(set), # PREDATOR
}
# Cells that contain each type (for fast iteration)
self._type_cells: Dict[int, Set[Tuple[int, int]]] = {
0: set(), # cells with PREY
1: set(), # cells with PREDATOR
}
def add(self, agent_id: int, x: float, y: float, agent_type: int = 0):
cell = (int(x), int(y))
self._grid[cell].add(agent_id)
self._type_grids[agent_type][cell].add(agent_id)
self._type_cells[agent_type].add(cell)
def remove(self, agent_id: int, x: float, y: float, agent_type: int = 0):
cell = (int(x), int(y))
self._grid[cell].discard(agent_id)
type_cell_set = self._type_grids[agent_type][cell]
type_cell_set.discard(agent_id)
if not type_cell_set:
self._type_cells[agent_type].discard(cell)
def move(self, agent_id: int, old_x: float, old_y: float, new_x: float, new_y: float, agent_type: int = 0):
"""Update agent's cell assignment. Only modifies grid if cell changed."""
old_cell = (int(old_x), int(old_y))
new_cell = (int(new_x), int(new_y))
if old_cell != new_cell:
self._grid[old_cell].discard(agent_id)
self._grid[new_cell].add(agent_id)
# Update type-specific grid
old_type_set = self._type_grids[agent_type][old_cell]
old_type_set.discard(agent_id)
if not old_type_set:
self._type_cells[agent_type].discard(old_cell)
self._type_grids[agent_type][new_cell].add(agent_id)
self._type_cells[agent_type].add(new_cell)
def agents_at(self, x: int, y: int) -> Set[int]:
return self._grid[(x, y)]
def agents_in_region(self, x1: int, y1: int, x2: int, y2: int) -> Set[int]:
"""Get all agent IDs in a rectangular region (for AOI queries)"""
result = set()
for x in range(x1, x2 + 1):
for y in range(y1, y2 + 1):
result.update(self._grid[(x, y)])
return result
def agents_of_type_in_region(self, x1: int, y1: int, x2: int, y2: int, agent_type: int) -> Set[int]:
"""
Get agent IDs of specific type in a rectangular region.
Optimized: only checks cells that actually contain agents of this type.
For sparse types (like predators), this is MUCH faster than agents_in_region.
"""
result = set()
type_cells = self._type_cells[agent_type]
# Only check cells that have this type
for cell in type_cells:
cx, cy = cell
if x1 <= cx <= x2 and y1 <= cy <= y2:
result.update(self._type_grids[agent_type][cell])
return result
class World:
"""
The simulation world state and logic.
Scalability considerations:
- Food is stored per-cell (not per-agent), so memory is O(cells) not O(agents)
- Spatial grid gives O(1) cell lookups
- Dirty set tracks which agents changed for delta updates
Biomes:
- 0: baseline (no modifiers)
- 1: desert (no seasons, reduced food, 1.2x metabolism/movement)
- 2: arctic (extreme seasons, reduced food, 1.5x metabolism/movement)
- 3: tropical (no seasons, bonus food)
- 7: wall (impassable, no food) - reserved for future
"""
# Biome constants
BIOME_BASELINE = 0
BIOME_DESERT = 1
BIOME_ARCTIC = 2
BIOME_TROPICAL = 3
BIOME_WALL = 7
# Biome modifiers: {biome: (food_mult, cost_mult, season_mult)}
# food_mult: multiplier for food regen and ceiling
# cost_mult: multiplier for metabolism and movement costs
# season_mult: multiplier for season effect (0 = no seasons, >1 = extreme seasons)
BIOME_MODIFIERS = {
0: (1.0, 1.0, 1.0), # baseline
1: (0.75, 1.1, 0.25), # desert: less food, higher cost, weak seasons
2: (0.75, 1.1, 1.25), # arctic: less food, higher cost, extreme seasons
3: (1.25, 1.0, 0.0), # tropical: more food, normal cost, no seasons
7: (0.0, 999, 0.0), # wall: no food, impassable
}
# Foraging logic
BEARING_ADJUST = 0.3 # 0.1 rad ~6 degrees, for prey (adjust towards more attractive pasture)
PREY_BEARING_DRIFT_STDDEV = 0.5
PREDATOR_BEARING_DRIFT_STDDEV = 0.1
_QUADRANT_CELLS = (
((1, 0), (1, 1), (1, -1)), # Q0: E, NE, SE
((0, 1), (1, 1), (-1, 1)), # Q1: N, NE, NW
((-1, 0), (-1, 1), (-1, -1)), # Q2: W, NW, SW
((0, -1), (1, -1), (-1, -1)), # Q3: S, SE, SW
)
# Simulation parameters
# Tuned for stable oscillating predator-prey dynamics
FOOD_REGEN_PER_TURN = 0.2
FOOD_CEILING = 4.0
SEASON_LENGTH = 40
SEASON_STRENGTH = 0.5 # in [0,1]
PREY_INITIAL_ENERGY = 2.0
PREY_MOVE_COST = 0.02 # Base cost for normal movement (foraging)
PREY_MOVE_SPEED = 0.1 # Base speed for normal movement
PREY_SPRINT_RATIO = 4.0 # Speed multiplier when fleeing
PREY_SPRINT_COST = 6.0 # Cost multiplier when fleeing (actual = MOVE_COST * SPRINT_COST)
PREY_METABOLISM_COST = 0.05
PREY_REPRODUCTION_THRESHOLD = 2*PREY_INITIAL_ENERGY
PREY_REPRODUCTION_COST = PREY_INITIAL_ENERGY
PREY_EAT_RATE = 0.8 # max food eaten per turn
PREY_VISION_RANGE = 5 # max vision range for prey
PREY_VISION_COST = 0.01
PREY_DEFAULT_VISION = 0.3 # founder population vision range (0: start blind)
PREY_SENESCENCE = 160
PREY_AGE_PENALTY = 0.1
PREY_MAX_ENERGY = 10.0 # Stop foraging above this (satiated)
PREY_MALE_INVESTMENT = 0.3 # Male pays this fraction of reproduction cost
PREDATOR_INITIAL_ENERGY = 6.0
PREDATOR_MOVE_COST = 0.01 # Base cost for normal movement (patrolling)
PREDATOR_MOVE_SPEED = 0.1 # Base speed for normal movement
PREDATOR_SPRINT_RATIO = 4.5 # Speed multiplier when pursuing
PREDATOR_SPRINT_COST = 5.0 # Cost multiplier when pursuing
PREDATOR_METABOLISM_COST = 0.01
PREDATOR_REPRODUCTION_THRESHOLD = 2*PREDATOR_INITIAL_ENERGY
PREDATOR_REPRODUCTION_COST = PREDATOR_INITIAL_ENERGY*2
PREDATOR_ENERGY_FROM_PREY = 3.5 # fixed value, or have it depend on prey energy?
PREDATOR_VISION_RANGE = 5 # max vision range for predators
PREDATOR_VISION_COST = 0.01
PREDATOR_DEFAULT_VISION = 3 # founder population vision range
PREDATOR_SENESCENCE = 500
PREDATOR_AGE_PENALTY = 0.1
PREDATOR_MAX_ENERGY = 30.0 # Stop hunting above this (satiated)
PREDATOR_MALE_INVESTMENT = 0.48 # Male pays this fraction of reproduction cost
PREDATOR_HEAT = 10.0 # Baseline mating priority exponent (lower = more mating focus)
MATING_DISTANCE = 1.1 # Max distance for mating (needs vision to see mates outside own cell)
CATCH_DIST_SQ = 0.15 * 0.15 # Predator catches prey within this distance (square)
# Speciation and combat (prey only for now)
PREY_SPECIATION_THRESHOLD_SQ = 1.0 # Genetic distance^2 beyond which agents are different species
PREY_GENE_MUTATION_SD = 0.4 # Mutation rate for speciation genes
PREY_AGGRESSION_MUTATION_SD = 0.2 # Mutation rate for aggression gene
PREY_ATTACK_COST = 1.0 # Energy spent to attack
PREY_ATTACK_DAMAGE = 3.0 # Energy dealt to target
PREY_INTRASPECIES_MODIFIER = 0.3 # modifies attack probability/cost/damage for intraspecies aggression
PREY_FEMALE_AGGRESSION_MULT = 0.5 # Females are less aggressive
PREY_DEFAULT_AGGRESSION = 0.3 # Founders start non-aggressive
PREY_ATTACK_REPULSION = 0.15 # Distance attacker is pushed back after interspecies attack
CONSENSUS_STRENGTH = 0.8 # foraging prey herd behavior
# Default board size and founder populations
DEFAULT_WIDTH = 100
DEFAULT_HEIGHT = 100
DEFAULT_INITIAL_PREY = 1000
DEFAULT_INITIAL_PREDATORS = 15
# Statistics logging
STATS_LOG_INTERVAL = 10 # Print stats every N ticks (0 to disable)
HIST_BINS = 10 #number of bins in histograms
def __init__(self, width: int, height: int, seed: Optional[int] = None):
self.width = width
self.height = height
self.tick = 0
if seed is not None:
random.seed(seed)
# Biome per cell (generated after construction via generate_biomes)
self.biome: Dict[Tuple[int, int], int] = {}
for x in range(width):
for y in range(height):
self.biome[(x, y)] = self.BIOME_BASELINE
# Food per cell (initialized after biomes are set)
self.food: Dict[Tuple[int, int], float] = {}
for x in range(width):
for y in range(height):
food_mult, _, _ = self.BIOME_MODIFIERS[self.biome[(x, y)]]
self.food[(x, y)] = random.uniform(0, self.FOOD_CEILING * food_mult)
# Agent management
self._next_id = 0
self.agents: Dict[int, Agent] = {}
self.spatial_grid = SpatialGrid(width, height)
# Delta tracking: which agents have changed since last "mark_clean()"
self._dirty_agents: Set[int] = set()
self._spawned_agents: Set[int] = set() # new since last clean
self._despawned_agents: Set[int] = set() # removed since last clean
self._mated_this_tick: Set[int] = set() # agents who have mated this tick
self._fled_this_tick: Set[int] = set() # agents who fled this tick (skip combat)
# Per-tick statistics (reset each tick, accumulated for logging)
self._stats = {
'attacks_interspecies': 0,
'attacks_intraspecies': 0,
'attack_kills': 0,
'matings': 0,
'deaths_predation': 0,
'deaths_starvation': 0,
}
#statistics histograms, set default maxima
self._hist_max = {
'prey_age': 100, # initial defaults
'prey_energy': self.PREY_MAX_ENERGY,
'prey_speed': 1.0,
'prey_vision': self.PREY_VISION_RANGE,
'prey_brutpflege': 1.0,
'prey_aggression': -1, # sentinel value -1 means "it's a percentage", max fixed at 1.0
'predator_age': 100,
'predator_energy': self.PREDATOR_MAX_ENERGY,
'predator_speed': 1.0,
'predator_vision': self.PREDATOR_VISION_RANGE,
'predator_brutpflege': 1.0,
'predator_heat': 10.0,
}
self._histograms = {} # filled by _update_histograms
#how to report each property as histogram value
self.hist_value_getters = {
'age': lambda a: self.tick - a.born,
'energy': lambda a: a.energy,
'vision': lambda a: a.vision,
'brutpflege': lambda a: a.brutpflege,
'speed': lambda a: a.speed + (self.PREY_SPRINT_RATIO - 1 if a.agent_type == AgentType.PREY else self.PREDATOR_SPRINT_RATIO - 1),
'aggression': lambda a: a.aggression,
'heat': lambda a: a.heat,
}
# Simulation state
self.halted = False # Set to True when termination condition is met
self.halt_reason: Optional[str] = None # Why simulation halted
# Deferred command execution (for commands received during step())
self._stepping = False # True while inside step()
self._deferred_commands: List[Tuple[str, any]] = [] # Queue of (name, value) to execute after step
def generate_biomes(self):
"""
Generate biome map based on latitude (y coordinate).
Default layout for 100-height world:
- y in [0, 19]: arctic (2)
- y in [20, 49]: baseline (0)
- y in [50, 69]: desert (1)
- y in [70, 99]: tropical (3)
Scales proportionally for other world heights.
Additionally, longitudinal walls are placed at:
- x % 30 == 10 and y % 25 < 10
"""
for x in range(self.width):
for y in range(self.height):
# Check for wall first (longitudinal barriers)
if x % 30 == 10 and y % 25 < 10:
biome = self.BIOME_WALL
else:
# Latitude-based biome assignment
y_norm = y / self.height
if y_norm < 0.2:
biome = self.BIOME_ARCTIC
elif y_norm < 0.5:
biome = self.BIOME_BASELINE
elif y_norm < 0.7:
biome = self.BIOME_DESERT
else:
biome = self.BIOME_TROPICAL
self.biome[(x, y)] = biome
# Reinitialize food based on new biomes
self._init_food_from_biomes()
def load_biome_map(self, filename: str):
"""
Load biome map from a text file.
File format:
- Each line is a row (line 0 = y=0)
- Each character is a column (char 0 = x=0)
- Characters '0'-'7' map to biome codes
The world dimensions are set from the map file.
Args:
filename: Path to the map file
Raises:
ValueError: If map dimensions don't match or invalid characters found
"""
with open(filename, 'r') as f:
lines = [line.rstrip('\n\r') for line in f.readlines()]
# Filter out empty lines
lines = [line for line in lines if line]
if not lines:
raise ValueError("Empty map file")
map_height = len(lines)
map_width = len(lines[0])
# Verify all lines have same width
for i, line in enumerate(lines):
if len(line) != map_width:
raise ValueError(f"Line {i} has width {len(line)}, expected {map_width}")
# Check if dimensions match world (resize if needed)
if map_width != self.width or map_height != self.height:
# Resize world to match map
self.width = map_width
self.height = map_height
self.spatial_grid = SpatialGrid(map_width, map_height)
# Reinitialize biome and food dicts
self.biome = {}
self.food = {}
for x in range(map_width):
for y in range(map_height):
self.biome[(x, y)] = self.BIOME_BASELINE
self.food[(x, y)] = 0.0
# Parse map
for y, line in enumerate(lines):
for x, char in enumerate(line):
if char not in '01234567':
raise ValueError(f"Invalid character '{char}' at ({x}, {y})")
self.biome[(x, y)] = int(char)
# Initialize food based on biomes
self._init_food_from_biomes()
def _init_food_from_biomes(self):
"""Initialize food values based on biome modifiers."""
for x in range(self.width):
for y in range(self.height):
food_mult, _, _ = self.BIOME_MODIFIERS[self.biome[(x, y)]]
self.food[(x, y)] = random.uniform(0, self.FOOD_CEILING * food_mult)
def is_wall(self, x: int, y: int) -> bool:
"""Check if a cell is a wall (impassable)."""
return self.biome.get((x, y), self.BIOME_BASELINE) == self.BIOME_WALL
def is_passable(self, x: float, y: float) -> bool:
"""Check if a position is passable (not a wall, within bounds)."""
ix, iy = int(x), int(y)
if not (0 <= ix < self.width and 0 <= iy < self.height):
return False
return self.biome[(ix, iy)] != self.BIOME_WALL
def get_biome_modifier(self, x: float, y: float) -> Tuple[float, float, float]:
"""
Get biome modifiers for a position.
Returns (food_mult, cost_mult, season_mult) for the cell containing (x, y).
"""
cell = (int(x), int(y))
biome = self.biome.get(cell, self.BIOME_BASELINE)
return self.BIOME_MODIFIERS.get(biome, self.BIOME_MODIFIERS[self.BIOME_BASELINE])
@classmethod
def create(cls, **kwargs) -> 'World':
"""
Create and initialize a world with agents.
This is the main entry point for creating a simulation world.
The server passes an opaque config dict - only this method knows
what parameters are valid and what they mean.
Args (all optional, with defaults):
width: World width in cells (default: 100, ignored if map_file provided)
height: World height in cells (default: 100, ignored if map_file provided)
initial_prey: Number of prey to spawn (default: 1000)
initial_predators: Number of predators to spawn (default: 15)
seed: Random seed for reproducibility (default: None)
biomes: Whether to generate biomes (default: True, ignored if map_file provided)
map_file: Path to biome map file (default: None)
Returns:
Initialized World with agents spawned and dirty state cleared
"""
# Extract and apply defaults - only this method knows the parameter names
width = kwargs.get('width', cls.DEFAULT_WIDTH)
height = kwargs.get('height', cls.DEFAULT_HEIGHT)
initial_prey = kwargs.get('initial_prey', cls.DEFAULT_INITIAL_PREY)
initial_predators = kwargs.get('initial_predators', cls.DEFAULT_INITIAL_PREDATORS)
seed = kwargs.get('seed', None)
biomes = kwargs.get('biomes', True)
map_file = kwargs.get('map_file', 'maps/map2.txt') # 'maps/map1.txt'
# Create the world
world = cls(width, height, seed=seed)
# Load biome map from file, or generate procedurally
if map_file:
world.load_biome_map(map_file)
# Update width/height in case map resized the world
width = world.width
height = world.height
elif biomes:
world.generate_biomes()
# Spawn initial prey at random float positions (retry if on wall)
for _ in range(initial_prey):
for attempt in range(100): # Max retries to avoid infinite loop
x = random.random() * width
y = random.random() * height
if world.is_passable(x, y):
world.spawn_agent(AgentType.PREY, x, y, cls.PREY_INITIAL_ENERGY)
break
# Spawn initial predators as breeding pairs (male + female per cell)
# initial_predators specifies number of pairs, so total = initial_predators * 2
for _ in range(initial_predators):
for attempt in range(100):
# Pick a random cell
cell_x = random.randint(0, width - 1)
cell_y = random.randint(0, height - 1)
if world.is_passable(cell_x + 0.5, cell_y + 0.5):
# Spawn male at random position in cell
male_x = cell_x + random.random()
male_y = cell_y + random.random()
male = world.spawn_agent(AgentType.PREDATOR, male_x, male_y, cls.PREDATOR_INITIAL_ENERGY)
male.sex = 0 # Force male
# Spawn female at different random position in same cell
female_x = cell_x + random.random()
female_y = cell_y + random.random()
female = world.spawn_agent(AgentType.PREDATOR, female_x, female_y, cls.PREDATOR_INITIAL_ENERGY)
female.sex = 1 # Force female
break
# Clear dirty state so first delta is clean
world.mark_clean()
return world
def _allocate_id(self) -> int:
"""Get a unique agent ID. IDs are never reused (important for client sync)."""
agent_id = self._next_id
self._next_id += 1
return agent_id
def spawn_agent(self, agent_type: AgentType, x: float, y: float, energy: float,
parent_ids: Tuple[int, int] = (-1, -1)) -> Agent:
"""
Create a new agent in the world.
Args:
agent_type: Type of agent (PREY or PREDATOR)
x, y: Position (float coordinates)
energy: Initial energy
parent_ids: Tuple of parent IDs ((-1, -1) for founders)
Returns:
The created Agent
Genetic inheritance:
- Founders (parent_ids == (-1, -1)) get type defaults
- Offspring inherit average of both parents' genes + Gaussian noise (sd=0.2)
- Vision and brutpflege clamped >= 0
- Speed clamped to minimum that keeps sprint ratio >= 1
Sex is assigned randomly (50/50).
"""
agent_id = self._allocate_id()
# Determine minimum speed based on agent type
if agent_type == AgentType.PREY:
min_speed = -(self.PREY_SPRINT_RATIO - 1)
else:
min_speed = -(self.PREDATOR_SPRINT_RATIO - 1)
# Determine genetic parameters
parent1_id, parent2_id = parent_ids
parent1 = self.agents.get(parent1_id) if parent1_id >= 0 else None
parent2 = self.agents.get(parent2_id) if parent2_id >= 0 else None
if parent1 and parent2:
# Sexual reproduction: average parents' genes, then add mutation
avg_vision = (parent1.vision + parent2.vision) / 2
avg_brutpflege = (parent1.brutpflege + parent2.brutpflege) / 2
avg_speed = (parent1.speed + parent2.speed) / 2
vision = max(0.0, avg_vision + random.gauss(0, 0.2))
brutpflege = max(0.0, avg_brutpflege + random.gauss(0, 0.2))
speed = max(min_speed, avg_speed + random.gauss(0, 0.2))
# Heat: only mutate for predators, prey always 0
if agent_type == AgentType.PREDATOR:
avg_heat = (parent1.heat + parent2.heat) / 2
# Heat mutates multiplicatively, clamped to [0.005, 2]
heat = max(0.01, min(200.0, avg_heat * (1 + random.gauss(0, 0.1))))
aggression = 0 # Predators don't use aggression (yet)
genes = [] # Predators don't use speciation genes (yet)
else:
heat = 0
# Aggression: only for prey, average + mutation, clamped to [0, 1]
avg_aggression = (parent1.aggression + parent2.aggression) / 2
aggression = max(0.0, min(1.0, avg_aggression + random.gauss(0, self.PREY_AGGRESSION_MUTATION_SD)))
# Speciation genes: average + mutation, clamped to [0, 10]
genes = []
for i in range(3):
avg_gene = (parent1.genes[i] + parent2.genes[i]) / 2
genes.append(max(0.0, min(10.0, avg_gene + random.gauss(0, self.PREY_GENE_MUTATION_SD))))
else:
# Founders get type-specific defaults
if agent_type == AgentType.PREY:
vision = self.PREY_DEFAULT_VISION
heat = 0 # Prey don't use heat
aggression = self.PREY_DEFAULT_AGGRESSION
# species by map quarter (pending more versatile initialization options)
genes = [5.0+3*(1 if x<50 else -1), 5.0+3*(1 if y<50 else -1), 5.0+2*(1 if x<50 else -1)]
else:
vision = self.PREDATOR_DEFAULT_VISION
heat = self.PREDATOR_HEAT # Predators start at baseline heat
aggression = 0 # Not used for predators yet
genes = [] # Not used for predators yet
brutpflege = 0 # founders start at brutpflege=0
speed = 0 # founders start at baseline speed
# Random initial bearing and sex
bearing = (random.random() - 0.5) * TWOPI
sex = random.randint(0, 1) # 0 = male, 1 = female
agent = Agent(
id=agent_id,
agent_type=agent_type,
x=float(x),
y=float(y),
energy=energy,
sex=sex,
born=self.tick,
parent=list(parent_ids),
offspring=[],
vision=vision,
brutpflege=brutpflege,
speed=speed,
heat=heat,
aggression=aggression,
genes=genes,
bearing=bearing
)
self.agents[agent_id] = agent
self.spatial_grid.add(agent_id, x, y, agent_type.value)
self._spawned_agents.add(agent_id)
self._dirty_agents.add(agent_id)
# Record this agent as offspring of both parents
if parent1:
parent1.offspring.append(agent_id)
if parent2:
parent2.offspring.append(agent_id)
return agent
def remove_agent(self, agent_id: int):
"""Remove an agent from the world."""
if agent_id in self.agents:
agent = self.agents[agent_id]
self.spatial_grid.remove(agent_id, agent.x, agent.y, agent.agent_type.value)
del self.agents[agent_id]
self._despawned_agents.add(agent_id)
self._dirty_agents.discard(agent_id)
self._spawned_agents.discard(agent_id)
def _mark_dirty(self, agent_id: int):
"""Mark an agent as modified (for delta updates)."""
self._dirty_agents.add(agent_id)
def get_dirty_state(self) -> dict:
"""
Get all changes since last mark_clean().
This is what we'll send to clients as delta updates.
Uses to_display_dict() for phenotype-only data (efficient streaming).
Full agent data is available via inspect command.
"""
return {
'tick': self.tick,
'spawned': [self.agents[aid].to_display_dict() for aid in self._spawned_agents if aid in self.agents],
'updated': [self.agents[aid].to_display_dict() for aid in self._dirty_agents - self._spawned_agents if aid in self.agents],
'despawned': list(self._despawned_agents)
}
def get_dirty_ids(self) -> dict:
"""
Get just the IDs of changed agents (no data copying).
Much more efficient for streaming - actual data fetched lazily at send time.
"""
return {
'tick': self.tick,
'spawned_ids': list(self._spawned_agents),
'updated_ids': list(self._dirty_agents - self._spawned_agents),
'despawned_ids': list(self._despawned_agents)
}
def get_full_state(self) -> dict:
"""
Get complete world state (for initial sync or reconnection).
Map cells are returned as entities alongside agents, with type=-1 (CELL).
This unified approach allows the same streaming infrastructure to handle
both agents and map data.
"""
# Agents as entities (display dict only for streaming efficiency)
agent_entities = [a.to_display_dict() for a in self.agents.values()]
# Map cells as entities (type=-1 indicates CELL)
cell_entities = [
{
'id': -(x * self.height + y + 1), # Negative IDs for cells
'type': -1, # CELL type
'x': x,
'y': y,
'food': round(self.food[(x, y)], 2),
'biome': self.biome[(x, y)]
}
for x in range(self.width)
for y in range(self.height)
]
return {
'tick': self.tick,
'width': self.width,
'height': self.height,
'agents': agent_entities,
'cells': cell_entities,
'total_entities': len(agent_entities) + len(cell_entities)
}
def describe_map(self) -> List[List[int]]:
"""
Get static map data for initial transmission to client.
Returns only cells with non-zero biome codes (client assumes biome 0 by default).
Each entry is [x, y, biome].
Returns:
List of [x, y, biome] for all cells where biome != 0
"""
return [
[x, y, biome]
for (x, y), biome in self.biome.items()
if biome != 0
]
def mark_clean(self):
"""Clear dirty tracking (call after sending delta to clients)."""
self._dirty_agents.clear()
self._spawned_agents.clear()
self._despawned_agents.clear()
def inspect_agent(self, agent_id: int) -> Optional[dict]:
"""
Get full state of a specific agent (for inspect command).
Returns None if agent doesn't exist.
"""
if agent_id in self.agents:
return self.agents[agent_id].to_full_dict()
return None
@staticmethod
def distance(p1, p2) -> float:
"""
Distance between two positions in the simulation's topology.
For this 2D grid simulation, uses Chebyshev distance (L∞ norm).
Other simulations might use Euclidean, Manhattan, graph distance, etc.
Args:
p1: First position (tuple/list of coordinates)
p2: Second position (tuple/list of coordinates)
Returns:
Distance as a float
"""
return max(abs(a - b) for a, b in zip(p1, p2))
@staticmethod
def is_in_viewport(position, center, radius) -> bool:
"""
Check if a position is within a viewport.
This is simulation-specific - defines what "within radius" means
for this simulation's topology.
Args:
position: Agent's position (tuple/list)
center: Viewport center (tuple/list)
radius: Viewport radius (float)
Returns:
True if position is within viewport
"""
return World.distance(position, center) <= radius
def get_viewport(self, center, radius) -> dict:
"""
Get all entities (agents and map cells) within a viewport region.
This is the simulation's implementation of viewport queries.
The server calls this without knowing the topology details.
Args:
center: Viewport center position (tuple/list of coordinates)
radius: Viewport radius
Returns:
{
'tick': current tick,
'center': center position as list,
'radius': radius,
'agents': list of entity dicts (agents + cells),
'count': number of entities
}
Entity types:
type >= 0: Agents (prey=0, predator=1, etc.)
type = -1: Map cells (food)
"""
entities = []
# Add agents in viewport
for agent in self.agents.values():
if self.is_in_viewport(agent.position, center, radius):
entities.append(agent.to_viewport_dict())
# Add map cells in viewport
for (x, y), food in self.food.items():
if self.is_in_viewport((x, y), center, radius):
entities.append({
'id': -(x * self.height + y + 1), # Negative IDs for cells
'type': -1, # CELL type
'x': x,
'y': y,
'food': round(food, 2)
})
return {
'tick': self.tick,
'center': list(center),
'radius': radius,
'agents': entities, # Named 'agents' for backward compatibility
'count': len(entities)
}
def _move_agent(self, agent: Agent, new_x: float, new_y: float) -> bool:
"""
Move an agent to a new position (float coordinates).
Returns:
True if move succeeded, False if blocked (wall or out of bounds)
"""
if new_x == agent.x and new_y == agent.y:
return True
# Clamp to world bounds (keep slightly inside to avoid edge issues)
new_x = max(0.0, min(float(self.width) - 0.001, new_x))
new_y = max(0.0, min(float(self.height) - 0.001, new_y))
# Check if destination cell is a wall
if not self.is_passable(new_x, new_y):
return False
self.spatial_grid.move(agent.id, agent.x, agent.y, new_x, new_y, agent.agent_type.value)
agent.x = new_x
agent.y = new_y
self._mark_dirty(agent.id)
return True
def _move_toward(self, agent: Agent, target_x: float, target_y: float, speed: float) -> float:
"""
Move agent toward target position at given speed.
Args:
agent: Agent to move
target_x, target_y: Target position
speed: Maximum distance to move
Returns:
Actual distance moved (0 if already at target)
"""
dx = target_x - agent.x
dy = target_y - agent.y
dist = math.sqrt(dx * dx + dy * dy)
if dist < 0.001: # Already there
return 0.0
if dist <= speed:
# Can reach target this tick
new_x, new_y = target_x, target_y
actual_dist = dist
else:
# Move speed units toward target
new_x = agent.x + speed * dx / dist
new_y = agent.y + speed * dy / dist
actual_dist = speed
self._move_agent(agent, new_x, new_y)
return actual_dist
def _move_away_from(self, agent: Agent, threat_x: float, threat_y: float, speed: float) -> float:
"""
Move agent away from threat position at given speed.
Args:
agent: Agent to move
threat_x, threat_y: Position to flee from
speed: Distance to move