Distribute agents across full map, add children, add corner buildings
Browse files- web/index.html: GEN_HOUSE_SLOTS (52 positions across whole map) replaces
old hash-to-bottom-strip logic; 4 corner apartments + diner/pharmacy added
to LOCATION_POSITIONS
- config/city.yaml: 4 corner apartment blocks (apt_northeast/northwest/
southeast/southwest) and Blue Moon Diner + SociMed Pharmacy added
- generator.py: 20% of generated agents are children (age 8-17) with
elementary/middle/high school student occupations and child backstories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- config/city.yaml +48 -0
- src/soci/agents/generator.py +20 -4
- web/index.html +47 -11
config/city.yaml
CHANGED
|
@@ -253,3 +253,51 @@ locations:
|
|
| 253 |
description: A tree-lined avenue on the west side, near the school and sports fields.
|
| 254 |
capacity: 40
|
| 255 |
connected_to: [house_kai, school, church, sports_field, street_north, street_south]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
description: A tree-lined avenue on the west side, near the school and sports fields.
|
| 254 |
capacity: 40
|
| 255 |
connected_to: [house_kai, school, church, sports_field, street_north, street_south]
|
| 256 |
+
|
| 257 |
+
# ===================
|
| 258 |
+
# CORNER APARTMENT BLOCKS --- expand residential to all four quadrants
|
| 259 |
+
# ===================
|
| 260 |
+
- id: apt_northeast
|
| 261 |
+
name: "Eastview Terrace"
|
| 262 |
+
zone: residential
|
| 263 |
+
description: A modern apartment block in the northeast corner, popular with hospital staff and factory workers.
|
| 264 |
+
capacity: 12
|
| 265 |
+
connected_to: [street_east, hospital, office_tower, diner]
|
| 266 |
+
|
| 267 |
+
- id: apt_northwest
|
| 268 |
+
name: "Hilltop Gardens"
|
| 269 |
+
zone: residential
|
| 270 |
+
description: A quiet residential complex on the northwest hilltop, overlooking the school and church.
|
| 271 |
+
capacity: 12
|
| 272 |
+
connected_to: [street_west, school, church]
|
| 273 |
+
|
| 274 |
+
- id: apt_southeast
|
| 275 |
+
name: "Riverside Commons"
|
| 276 |
+
zone: residential
|
| 277 |
+
description: A lively apartment complex near the sports field and factory district, popular with young workers.
|
| 278 |
+
capacity: 12
|
| 279 |
+
connected_to: [street_south, factory, sports_field, library]
|
| 280 |
+
|
| 281 |
+
- id: apt_southwest
|
| 282 |
+
name: "Orchard Hill Flats"
|
| 283 |
+
zone: residential
|
| 284 |
+
description: A peaceful residential block on the southwest edge of town, close to the library and park.
|
| 285 |
+
capacity: 12
|
| 286 |
+
connected_to: [street_south, library, sports_field]
|
| 287 |
+
|
| 288 |
+
# ===================
|
| 289 |
+
# NEW COMMERCIAL --- east side balance
|
| 290 |
+
# ===================
|
| 291 |
+
- id: diner
|
| 292 |
+
name: "Blue Moon Diner"
|
| 293 |
+
zone: commercial
|
| 294 |
+
description: A classic diner open early to late. Strong coffee, big plates, and regulars who treat it like a second home.
|
| 295 |
+
capacity: 12
|
| 296 |
+
connected_to: [street_east, hospital, factory, apt_northeast]
|
| 297 |
+
|
| 298 |
+
- id: pharmacy
|
| 299 |
+
name: "SociMed Pharmacy"
|
| 300 |
+
zone: commercial
|
| 301 |
+
description: A neighborhood pharmacy where the pharmacist knows everyone by name and stocks good candy.
|
| 302 |
+
capacity: 8
|
| 303 |
+
connected_to: [street_south, library, apartment_block_2, apt_southwest]
|
src/soci/agents/generator.py
CHANGED
|
@@ -112,7 +112,7 @@ QUIRKS_POOL = [
|
|
| 112 |
|
| 113 |
# Occupation categories for schedule variation
|
| 114 |
EVENING_SHIFT_JOBS = {"bartender", "chef", "waiter", "barista"}
|
| 115 |
-
STUDENT_OCCUPATIONS = {"college student"}
|
| 116 |
RETIRED_OCCUPATIONS = {"retired"}
|
| 117 |
PHYSICAL_JOBS = {"mechanic", "electrician", "plumber", "construction worker", "personal trainer"}
|
| 118 |
|
|
@@ -150,6 +150,12 @@ def _pick_name(gender: str, used_names: set[str]) -> str:
|
|
| 150 |
|
| 151 |
def _pick_occupation(age: int) -> tuple[str, str | None]:
|
| 152 |
"""Pick occupation based on age. Returns (title, work_location_id)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
if age >= 65 and random.random() < 0.7:
|
| 154 |
return "retired", None
|
| 155 |
if 18 <= age <= 22 and random.random() < 0.6:
|
|
@@ -239,7 +245,13 @@ def _generate_background(name: str, age: int, occupation: str, traits: dict[str,
|
|
| 239 |
first = name.split()[0]
|
| 240 |
|
| 241 |
# Age-based life stage
|
| 242 |
-
if age <=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
stage = f"{first} is a {age}-year-old finding their way in life"
|
| 244 |
elif age <= 35:
|
| 245 |
stage = f"{first} is a {age}-year-old building their career"
|
|
@@ -251,7 +263,11 @@ def _generate_background(name: str, age: int, occupation: str, traits: dict[str,
|
|
| 251 |
stage = f"{first} is a {age}-year-old enjoying their golden years"
|
| 252 |
|
| 253 |
# Occupation context
|
| 254 |
-
if occupation == "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
job_part = "After decades of work, they now fill their days with hobbies and neighborhood life."
|
| 256 |
elif occupation == "college student":
|
| 257 |
subjects = random.choice([
|
|
@@ -328,7 +344,7 @@ def generate_personas(count: int, city: City) -> list[Persona]:
|
|
| 328 |
for i in range(count):
|
| 329 |
gender = _pick_gender()
|
| 330 |
name = _pick_name(gender, used_names)
|
| 331 |
-
age = random.randint(18, 75)
|
| 332 |
occupation, work_location_id = _pick_occupation(age)
|
| 333 |
traits = _generate_traits()
|
| 334 |
values = _pick_values(traits)
|
|
|
|
| 112 |
|
| 113 |
# Occupation categories for schedule variation
|
| 114 |
EVENING_SHIFT_JOBS = {"bartender", "chef", "waiter", "barista"}
|
| 115 |
+
STUDENT_OCCUPATIONS = {"college student", "elementary student", "middle school student", "high school student"}
|
| 116 |
RETIRED_OCCUPATIONS = {"retired"}
|
| 117 |
PHYSICAL_JOBS = {"mechanic", "electrician", "plumber", "construction worker", "personal trainer"}
|
| 118 |
|
|
|
|
| 150 |
|
| 151 |
def _pick_occupation(age: int) -> tuple[str, str | None]:
|
| 152 |
"""Pick occupation based on age. Returns (title, work_location_id)."""
|
| 153 |
+
if age <= 11:
|
| 154 |
+
return "elementary student", "school"
|
| 155 |
+
if age <= 14:
|
| 156 |
+
return "middle school student", "school"
|
| 157 |
+
if age <= 17:
|
| 158 |
+
return "high school student", "school"
|
| 159 |
if age >= 65 and random.random() < 0.7:
|
| 160 |
return "retired", None
|
| 161 |
if 18 <= age <= 22 and random.random() < 0.6:
|
|
|
|
| 245 |
first = name.split()[0]
|
| 246 |
|
| 247 |
# Age-based life stage
|
| 248 |
+
if age <= 11:
|
| 249 |
+
stage = f"{first} is {age} years old and attends Soci Elementary School"
|
| 250 |
+
elif age <= 14:
|
| 251 |
+
stage = f"{first} is {age} years old and is in middle school"
|
| 252 |
+
elif age <= 17:
|
| 253 |
+
stage = f"{first} is {age} years old and is a high schooler at Soci School"
|
| 254 |
+
elif age <= 22:
|
| 255 |
stage = f"{first} is a {age}-year-old finding their way in life"
|
| 256 |
elif age <= 35:
|
| 257 |
stage = f"{first} is a {age}-year-old building their career"
|
|
|
|
| 263 |
stage = f"{first} is a {age}-year-old enjoying their golden years"
|
| 264 |
|
| 265 |
# Occupation context
|
| 266 |
+
if occupation == "elementary student":
|
| 267 |
+
job_part = "They love recess, have strong opinions about their favourite subjects, and make friends easily."
|
| 268 |
+
elif occupation in ("middle school student", "high school student"):
|
| 269 |
+
job_part = "They're navigating homework, friendships, and figuring out who they are."
|
| 270 |
+
elif occupation == "retired":
|
| 271 |
job_part = "After decades of work, they now fill their days with hobbies and neighborhood life."
|
| 272 |
elif occupation == "college student":
|
| 273 |
subjects = random.choice([
|
|
|
|
| 344 |
for i in range(count):
|
| 345 |
gender = _pick_gender()
|
| 346 |
name = _pick_name(gender, used_names)
|
| 347 |
+
age = random.randint(8, 17) if random.random() < 0.20 else random.randint(18, 75)
|
| 348 |
occupation, work_location_id = _pick_occupation(age)
|
| 349 |
traits = _generate_traits()
|
| 350 |
values = _pick_values(traits)
|
web/index.html
CHANGED
|
@@ -257,6 +257,23 @@ const ROADS = [
|
|
| 257 |
{ x1: 0.70, y1: 0.58, x2: 0.70, y2: 0.72, width: 6 },
|
| 258 |
];
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
// Building positions — spread across larger grid
|
| 261 |
const LOCATION_POSITIONS = {
|
| 262 |
// === RESIDENTIAL — Row 1: North houses ===
|
|
@@ -300,6 +317,14 @@ const LOCATION_POSITIONS = {
|
|
| 300 |
town_square: { x: 0.50, y: 0.50, type: 'square', label: 'Town Square' },
|
| 301 |
sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' },
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
// === STREETS ===
|
| 304 |
street_north: { x: 0.50, y: 0.28, type: 'street', label: 'N. Main St' },
|
| 305 |
street_south: { x: 0.50, y: 0.58, type: 'street', label: 'S. Main St' },
|
|
@@ -657,22 +682,33 @@ function draw() {
|
|
| 657 |
let _genPosCache = {};
|
| 658 |
function getEffectivePositions() {
|
| 659 |
const result = {...LOCATION_POSITIONS};
|
| 660 |
-
// Add any locations from server data that we don't have positions for
|
| 661 |
for (const locId of Object.keys(locations)) {
|
| 662 |
if (!result[locId]) {
|
| 663 |
if (!_genPosCache[locId]) {
|
| 664 |
-
// Hash-based position in residential rows
|
| 665 |
let h = 0;
|
| 666 |
for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0;
|
| 667 |
-
const
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
}
|
| 677 |
result[locId] = _genPosCache[locId];
|
| 678 |
}
|
|
|
|
| 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 = {
|
| 279 |
// === RESIDENTIAL — Row 1: North houses ===
|
|
|
|
| 317 |
town_square: { x: 0.50, y: 0.50, type: 'square', label: 'Town Square' },
|
| 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 |
+
|
| 328 |
// === STREETS ===
|
| 329 |
street_north: { x: 0.50, y: 0.28, type: 'street', label: 'N. Main St' },
|
| 330 |
street_south: { x: 0.50, y: 0.58, type: 'street', label: 'S. Main St' },
|
|
|
|
| 682 |
let _genPosCache = {};
|
| 683 |
function getEffectivePositions() {
|
| 684 |
const result = {...LOCATION_POSITIONS};
|
|
|
|
| 685 |
for (const locId of Object.keys(locations)) {
|
| 686 |
if (!result[locId]) {
|
| 687 |
if (!_genPosCache[locId]) {
|
|
|
|
| 688 |
let h = 0;
|
| 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 |
+
};
|
| 703 |
+
} else {
|
| 704 |
+
// Unknown location: spread evenly across the whole map via hash
|
| 705 |
+
_genPosCache[locId] = {
|
| 706 |
+
x: 0.05 + ((h >>> 0) % 18) / 18 * 0.90,
|
| 707 |
+
y: 0.05 + ((h >>> 4) % 16) / 16 * 0.90,
|
| 708 |
+
type: 'house',
|
| 709 |
+
label: (locations[locId]?.name || locId).slice(0, 14),
|
| 710 |
+
};
|
| 711 |
+
}
|
| 712 |
}
|
| 713 |
result[locId] = _genPosCache[locId];
|
| 714 |
}
|