Fix agent clustering: valid house slots, even street routing, wider crowd spread
Browse filesRoot 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 +8 -2
- src/soci/world/city.py +9 -6
- web/index.html +31 -29
|
@@ -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 |
-
#
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.")
|
|
@@ -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 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 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 |
-
#
|
| 178 |
-
street =
|
| 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 |
|
|
@@ -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 —
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 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.
|
| 322 |
-
apt_northwest: { x: 0.
|
| 323 |
-
apt_southeast: { x: 0.93, y: 0.
|
| 324 |
-
apt_southwest: { x: 0.
|
| 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
|
| 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:
|
| 699 |
-
y:
|
| 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' ?
|
| 1695 |
-
const maxPerRow = pos.type === 'house' ?
|
| 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 *
|
| 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.
|
| 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 |
}
|