RayMelius Claude Sonnet 4.6 commited on
Commit
ff7a12f
·
1 Parent(s): efc4478

Fix agent clustering: valid house slots, even street routing, wider crowd spread

Browse files

Root causes of 66-agent pile-up:
1. GEN_HOUSE_SLOTS had top row at y=0.06 and interior at y=0.13 (sky/mountain).
All 55 slots now in valid ground area (y≥0.20), spread across full map width.
2. Corner apartments apt_northeast/northwest were at y=0.10 (sky); moved to y=0.22.
apt_southeast/southwest moved from y=0.86 to y=0.78.
3. generate_houses() used random.choice(all_public_zones) — most houses ended up
connecting only through street_north, creating a commute bottleneck. Now cycles
deterministically through the 4 named streets (N/S/E/W) so traffic is evenly split.
4. generate_personas() was cycling through ALL residential (named + generated),
leaving house_gen_64..80 empty. Now uses only house_gen_XX homes for generated
agents, so every generated house gets exactly 1 occupant.
5. computeAgentTarget crowd spread: maxPerRow 8→13, base radius 24→38, row step
14→24px — 66 agents spread ~3× wider than before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

src/soci/agents/generator.py CHANGED
@@ -332,8 +332,14 @@ def _assign_locations(
332
 
333
  def generate_personas(count: int, city: City) -> list[Persona]:
334
  """Generate `count` unique personas with assigned home/work locations."""
335
- # Gather location pools
336
- residential_ids = [lid for lid, loc in city.locations.items() if loc.zone == "residential"]
 
 
 
 
 
 
337
 
338
  if not residential_ids:
339
  raise ValueError("City has no residential locations — cannot assign homes.")
 
332
 
333
  def generate_personas(count: int, city: City) -> list[Persona]:
334
  """Generate `count` unique personas with assigned home/work locations."""
335
+ # Assign generated agents to GENERATED houses only (house_gen_XX).
336
+ # Named homes are reserved for YAML personas, preventing empty generated houses.
337
+ residential_ids = [lid for lid, loc in city.locations.items()
338
+ if loc.zone == "residential" and lid.startswith("house_gen_")]
339
+ if not residential_ids:
340
+ # Fallback: use all residential (e.g., standalone run without YAML personas)
341
+ residential_ids = [lid for lid, loc in city.locations.items()
342
+ if loc.zone == "residential"]
343
 
344
  if not residential_ids:
345
  raise ValueError("City has no residential locations — cannot assign homes.")
src/soci/world/city.py CHANGED
@@ -164,18 +164,21 @@ def generate_houses(city: City, count: int) -> list[str]:
164
  Also scales up capacity of commercial, work, and public locations
165
  proportionally to handle the larger population.
166
  """
167
- streets = [lid for lid, loc in city.locations.items()
168
- if "street" in lid or loc.zone == "public"]
169
- commercial = [lid for lid, loc in city.locations.items()
170
- if loc.zone == "commercial"]
 
171
  if not streets:
172
  streets = list(city.locations.keys())[:2]
 
 
173
 
174
  house_ids: list[str] = []
175
  for i in range(count):
176
  hid = f"house_gen_{i+1:02d}"
177
- # Connect to one street and 1-2 random commercial/public places
178
- street = random.choice(streets)
179
  extras = random.sample(commercial, k=min(random.randint(1, 2), len(commercial)))
180
  connections = list({street} | set(extras))
181
 
 
164
  Also scales up capacity of commercial, work, and public locations
165
  proportionally to handle the larger population.
166
  """
167
+ # Use only the 4 named streets (not all public zones) and cycle through them
168
+ # evenly so agents distribute across all 4 streets instead of bottlenecking one.
169
+ streets = [lid for lid in city.locations if lid.startswith("street_")]
170
+ if not streets:
171
+ streets = [lid for lid, loc in city.locations.items() if loc.zone == "public"]
172
  if not streets:
173
  streets = list(city.locations.keys())[:2]
174
+ commercial = [lid for lid, loc in city.locations.items()
175
+ if loc.zone == "commercial"]
176
 
177
  house_ids: list[str] = []
178
  for i in range(count):
179
  hid = f"house_gen_{i+1:02d}"
180
+ # Cycle evenly through streets: house 1→street_north, 2→street_south, etc.
181
+ street = streets[i % len(streets)]
182
  extras = random.sample(commercial, k=min(random.randint(1, 2), len(commercial)))
183
  connections = list({street} | set(extras))
184
 
web/index.html CHANGED
@@ -257,22 +257,26 @@ const ROADS = [
257
  { x1: 0.70, y1: 0.58, x2: 0.70, y2: 0.72, width: 6 },
258
  ];
259
 
260
- // Slot grid for procedurally generated houses — 52 positions covering the whole map
261
- const GEN_HOUSE_SLOTS = (function() {
262
- const s = [];
263
- for (let i = 0; i < 10; i++) s.push({x: 0.04 + i * 0.092, y: 0.06 + (i % 3) * 0.025});
264
- for (let i = 0; i < 7; i++) s.push({x: 0.02, y: 0.19 + i * 0.10});
265
- for (let i = 0; i < 7; i++) s.push({x: 0.97, y: 0.19 + i * 0.10});
266
- for (let i = 0; i < 10; i++) s.push({x: 0.04 + i * 0.092, y: 0.88 + (i % 3) * 0.025});
267
- const interior = [
268
- [0.13,0.14],[0.28,0.14],[0.44,0.13],[0.58,0.14],[0.73,0.13],[0.87,0.14],
269
- [0.13,0.80],[0.28,0.80],[0.44,0.81],[0.58,0.80],[0.73,0.81],[0.87,0.80],
270
- [0.93,0.22],[0.93,0.45],[0.93,0.57],[0.93,0.74],
271
- [0.03,0.22],[0.03,0.45],[0.03,0.57],[0.03,0.74],
272
- ];
273
- for (const [x,y] of interior) s.push({x,y});
274
- return s; // 52 slots
275
- })();
 
 
 
 
276
 
277
  // Building positions — spread across larger grid
278
  const LOCATION_POSITIONS = {
@@ -318,10 +322,10 @@ const LOCATION_POSITIONS = {
318
  sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' },
319
 
320
  // === NEW BUILDINGS (corner apartments + east-side commercial) ===
321
- apt_northeast: { x: 0.93, y: 0.10, type: 'apartment', label: 'Eastview Terrace' },
322
- apt_northwest: { x: 0.04, y: 0.10, type: 'apartment', label: 'Hilltop Gardens' },
323
- apt_southeast: { x: 0.93, y: 0.86, type: 'apartment', label: 'Riverside Commons' },
324
- apt_southwest: { x: 0.04, y: 0.86, type: 'apartment', label: 'Orchard Hill Flats' },
325
  diner: { x: 0.92, y: 0.36, type: 'shop', label: 'Blue Moon Diner' },
326
  pharmacy: { x: 0.35, y: 0.78, type: 'shop', label: 'SociMed Pharmacy' },
327
 
@@ -689,14 +693,12 @@ function getEffectivePositions() {
689
  for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0;
690
  const genMatch = locId.match(/house_gen_(\d+)/);
691
  if (genMatch) {
692
- // Deterministic slot assignment: house_gen_01 slot 0, house_gen_02 slot 1, …
693
  const slotIdx = (parseInt(genMatch[1]) - 1) % GEN_HOUSE_SLOTS.length;
694
  const sl = GEN_HOUSE_SLOTS[slotIdx];
695
- const jx = ((h >>> 8) & 0xf) * 0.004 - 0.028;
696
- const jy = ((h >>> 12) & 0xf) * 0.004 - 0.028;
697
  _genPosCache[locId] = {
698
- x: Math.max(0.01, Math.min(0.99, sl.x + jx)),
699
- y: Math.max(0.01, Math.min(0.99, sl.y + jy)),
700
  type: 'house',
701
  label: (locations[locId]?.name || locId).slice(0, 14),
702
  };
@@ -1691,16 +1693,16 @@ function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
1691
  };
1692
  } else {
1693
  // Multi-row arrangement for large crowds
1694
- const baseRadius = pos.type === 'house' ? 16 : (pos.type === 'park' || pos.type === 'square' || pos.type === 'sports' ? 35 : 24);
1695
- const maxPerRow = pos.type === 'house' ? 4 : 8;
1696
  const row = Math.floor(localIdx / maxPerRow);
1697
  const colIdx = localIdx % maxPerRow;
1698
  const rowCount = Math.min(count - row * maxPerRow, maxPerRow);
1699
- const radius = baseRadius + row * 14;
1700
  const step = Math.PI / Math.max(rowCount + 1, 2);
1701
  const angle = step * (colIdx + 1);
1702
  const ox = Math.cos(angle) * radius - radius / 3;
1703
- const oy = Math.sin(angle) * radius * 0.45 + (pos.type === 'house' ? 20 : 28) + row * 6;
1704
 
1705
  agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy };
1706
  }
 
257
  { x1: 0.70, y1: 0.58, x2: 0.70, y2: 0.72, width: 6 },
258
  ];
259
 
260
+ // Slot grid for procedurally generated houses — all slots in VALID city ground (y≥0.20).
261
+ // Horizon is at y=0.14; sky/mountain occupy y<0.20. Slots cover the full map width.
262
+ const GEN_HOUSE_SLOTS = [
263
+ // North strip: gaps between named north houses (y=0.19) and commercial row (y=0.34)
264
+ {x:0.04,y:0.21},{x:0.16,y:0.21},{x:0.30,y:0.21},{x:0.54,y:0.21},{x:0.70,y:0.21},{x:0.86,y:0.21},{x:0.94,y:0.21},
265
+ {x:0.04,y:0.27},{x:0.16,y:0.27},{x:0.30,y:0.27},{x:0.44,y:0.27},{x:0.56,y:0.27},{x:0.70,y:0.27},{x:0.84,y:0.27},{x:0.94,y:0.27},
266
+ // West column: left edge clear of school (x=0.08, y=0.50)
267
+ {x:0.03,y:0.36},{x:0.03,y:0.42},{x:0.03,y:0.56},{x:0.03,y:0.62},
268
+ // East column: right edge clear of hospital (x=0.92, y=0.50)
269
+ {x:0.96,y:0.36},{x:0.96,y:0.42},{x:0.96,y:0.56},{x:0.96,y:0.62},
270
+ // Interior mid-north gaps (between commercial strip rows, y=0.40-0.46)
271
+ {x:0.14,y:0.40},{x:0.44,y:0.40},{x:0.72,y:0.40},{x:0.84,y:0.40},
272
+ // Interior mid-south gaps (y=0.54-0.62)
273
+ {x:0.14,y:0.56},{x:0.30,y:0.56},{x:0.44,y:0.56},{x:0.70,y:0.56},{x:0.82,y:0.56},
274
+ // South strip: gaps between south named houses (y=0.65) and south zone buildings (y=0.78)
275
+ {x:0.04,y:0.66},{x:0.30,y:0.66},{x:0.54,y:0.66},{x:0.70,y:0.66},{x:0.86,y:0.66},{x:0.94,y:0.66},
276
+ {x:0.04,y:0.72},{x:0.16,y:0.72},{x:0.30,y:0.72},{x:0.46,y:0.72},{x:0.56,y:0.72},{x:0.70,y:0.72},{x:0.84,y:0.72},{x:0.94,y:0.72},
277
+ // Bottom fringe (y=0.82)
278
+ {x:0.14,y:0.82},{x:0.30,y:0.82},{x:0.44,y:0.82},{x:0.56,y:0.82},{x:0.70,y:0.82},{x:0.86,y:0.82},{x:0.96,y:0.82},
279
+ ];
280
 
281
  // Building positions — spread across larger grid
282
  const LOCATION_POSITIONS = {
 
322
  sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' },
323
 
324
  // === NEW BUILDINGS (corner apartments + east-side commercial) ===
325
+ apt_northeast: { x: 0.93, y: 0.22, type: 'apartment', label: 'Eastview Terrace' },
326
+ apt_northwest: { x: 0.07, y: 0.22, type: 'apartment', label: 'Hilltop Gardens' },
327
+ apt_southeast: { x: 0.93, y: 0.78, type: 'apartment', label: 'Riverside Commons' },
328
+ apt_southwest: { x: 0.07, y: 0.78, type: 'apartment', label: 'Orchard Hill Flats' },
329
  diner: { x: 0.92, y: 0.36, type: 'shop', label: 'Blue Moon Diner' },
330
  pharmacy: { x: 0.35, y: 0.78, type: 'shop', label: 'SociMed Pharmacy' },
331
 
 
693
  for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0;
694
  const genMatch = locId.match(/house_gen_(\d+)/);
695
  if (genMatch) {
696
+ // Deterministic slot no jitter so houses never drift into sky/mountain
697
  const slotIdx = (parseInt(genMatch[1]) - 1) % GEN_HOUSE_SLOTS.length;
698
  const sl = GEN_HOUSE_SLOTS[slotIdx];
 
 
699
  _genPosCache[locId] = {
700
+ x: sl.x,
701
+ y: sl.y,
702
  type: 'house',
703
  label: (locations[locId]?.name || locId).slice(0, 14),
704
  };
 
1693
  };
1694
  } else {
1695
  // Multi-row arrangement for large crowds
1696
+ const baseRadius = pos.type === 'house' ? 18 : (pos.type === 'park' || pos.type === 'square' || pos.type === 'sports' ? 55 : 38);
1697
+ const maxPerRow = pos.type === 'house' ? 5 : 13;
1698
  const row = Math.floor(localIdx / maxPerRow);
1699
  const colIdx = localIdx % maxPerRow;
1700
  const rowCount = Math.min(count - row * maxPerRow, maxPerRow);
1701
+ const radius = baseRadius + row * 24;
1702
  const step = Math.PI / Math.max(rowCount + 1, 2);
1703
  const angle = step * (colIdx + 1);
1704
  const ox = Math.cos(angle) * radius - radius / 3;
1705
+ const oy = Math.sin(angle) * radius * 0.5 + (pos.type === 'house' ? 22 : 34) + row * 10;
1706
 
1707
  agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy };
1708
  }