RayMelius Claude Sonnet 4.6 commited on
Commit
29d9da4
Β·
1 Parent(s): d7416cd

Fix apartment assignment, player sleep, gender profile, and NPC conversations

Browse files

- Player now gets assigned to an available apartment block instead of town_square
- Players auto-sleep at home during sleeping hours (23:00-06:00) and auto-wake
- Added gender field (male/female/nonbinary) to player profile modal and API
- Gender-based attraction: only opposite genders (or nonbinary/unknown) develop romance
- Players now included in romance system (_tick_romance no longer skips is_player)
- Fixed NPC conversations: replaced is_busy check with conversable-state check
so agents eating/relaxing/working can still initiate conversations
- Doubled conversation base chance (extraversion/20 instead of /40)
- Raised max simultaneous conversations cap from 2 to 3

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

src/soci/actions/social.py CHANGED
@@ -17,7 +17,7 @@ def should_initiate_conversation(agent: Agent, other_id: str, clock: SimClock) -
17
  return False
18
 
19
  # Extraversion drives conversation initiation
20
- base_chance = agent.persona.extraversion / 40.0 # 0.025 to 0.25
21
 
22
  # Boost if social need is low (lonely agents seek conversation)
23
  if agent.needs.social < 0.3:
 
17
  return False
18
 
19
  # Extraversion drives conversation initiation
20
+ base_chance = agent.persona.extraversion / 20.0 # 0.05 to 0.5
21
 
22
  # Boost if social need is low (lonely agents seek conversation)
23
  if agent.needs.social < 0.3:
src/soci/api/routes.py CHANGED
@@ -33,6 +33,7 @@ class PlayerCreateRequest(BaseModel):
33
  age: int = 30
34
  occupation: str = "Newcomer"
35
  background: str = "A newcomer to Soci City."
 
36
  extraversion: int = 5
37
  agreeableness: int = 7
38
  openness: int = 6
@@ -44,6 +45,7 @@ class PlayerUpdateRequest(BaseModel):
44
  age: Optional[int] = None
45
  occupation: Optional[str] = None
46
  background: Optional[str] = None
 
47
  extraversion: Optional[int] = None
48
  agreeableness: Optional[int] = None
49
  openness: Optional[int] = None
@@ -67,6 +69,22 @@ class PlayerPlanRequest(BaseModel):
67
 
68
  # ── Helper ────────────────────────────────────────────────────────────────────
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  async def _get_player_from_token(token: str):
71
  """Validate token and return (user, agent). Raises 401/404 on failure."""
72
  from soci.api.server import get_simulation, get_database
@@ -283,7 +301,7 @@ async def auth_register(request: AuthRequest):
283
  except ValueError as e:
284
  raise HTTPException(status_code=409, detail=str(e))
285
 
286
- # Auto-create default player agent at town_square
287
  safe_name = request.username.strip()
288
  player_id = f"player_{safe_name.lower().replace(' ', '_')}"
289
  # Ensure unique ID
@@ -293,6 +311,7 @@ async def auth_register(request: AuthRequest):
293
  player_id = f"{base_id}_{suffix}"
294
  suffix += 1
295
 
 
296
  persona = Persona(
297
  id=player_id,
298
  name=safe_name,
@@ -300,7 +319,7 @@ async def auth_register(request: AuthRequest):
300
  occupation="Newcomer",
301
  gender="unknown",
302
  background="A newcomer to Soci City.",
303
- home_location="town_square",
304
  work_location="",
305
  extraversion=5,
306
  agreeableness=7,
@@ -379,13 +398,15 @@ async def player_create(request: PlayerCreateRequest):
379
  loc.remove_agent(old_id)
380
  del sim.agents[old_id]
381
 
 
382
  persona = Persona(
383
  id=player_id,
384
  name=request.name,
385
  age=request.age,
386
  occupation=request.occupation,
 
387
  background=request.background,
388
- home_location="town_square",
389
  work_location="",
390
  extraversion=request.extraversion,
391
  agreeableness=request.agreeableness,
@@ -414,6 +435,8 @@ async def player_update(request: PlayerUpdateRequest):
414
  p.occupation = request.occupation
415
  if request.background is not None:
416
  p.background = request.background
 
 
417
  if request.extraversion is not None:
418
  p.extraversion = max(1, min(10, request.extraversion))
419
  if request.agreeableness is not None:
 
33
  age: int = 30
34
  occupation: str = "Newcomer"
35
  background: str = "A newcomer to Soci City."
36
+ gender: str = "unknown"
37
  extraversion: int = 5
38
  agreeableness: int = 7
39
  openness: int = 6
 
45
  age: Optional[int] = None
46
  occupation: Optional[str] = None
47
  background: Optional[str] = None
48
+ gender: Optional[str] = None
49
  extraversion: Optional[int] = None
50
  agreeableness: Optional[int] = None
51
  openness: Optional[int] = None
 
69
 
70
  # ── Helper ────────────────────────────────────────────────────────────────────
71
 
72
+ def _find_player_apartment(sim) -> str:
73
+ """Find an available residential location for a new player."""
74
+ preferred = [
75
+ "apartment_block_1", "apartment_block_2", "apartment_block_3",
76
+ "apt_northeast", "apt_northwest", "apt_southeast", "apt_southwest",
77
+ ]
78
+ for loc_id in preferred:
79
+ loc = sim.city.get_location(loc_id)
80
+ if loc and not loc.is_full:
81
+ return loc_id
82
+ for loc in sim.city.get_locations_in_zone("residential"):
83
+ if not loc.is_full:
84
+ return loc.id
85
+ return "town_square"
86
+
87
+
88
  async def _get_player_from_token(token: str):
89
  """Validate token and return (user, agent). Raises 401/404 on failure."""
90
  from soci.api.server import get_simulation, get_database
 
301
  except ValueError as e:
302
  raise HTTPException(status_code=409, detail=str(e))
303
 
304
+ # Auto-create default player agent with an apartment
305
  safe_name = request.username.strip()
306
  player_id = f"player_{safe_name.lower().replace(' ', '_')}"
307
  # Ensure unique ID
 
311
  player_id = f"{base_id}_{suffix}"
312
  suffix += 1
313
 
314
+ home = _find_player_apartment(sim)
315
  persona = Persona(
316
  id=player_id,
317
  name=safe_name,
 
319
  occupation="Newcomer",
320
  gender="unknown",
321
  background="A newcomer to Soci City.",
322
+ home_location=home,
323
  work_location="",
324
  extraversion=5,
325
  agreeableness=7,
 
398
  loc.remove_agent(old_id)
399
  del sim.agents[old_id]
400
 
401
+ home = _find_player_apartment(sim)
402
  persona = Persona(
403
  id=player_id,
404
  name=request.name,
405
  age=request.age,
406
  occupation=request.occupation,
407
+ gender=request.gender,
408
  background=request.background,
409
+ home_location=home,
410
  work_location="",
411
  extraversion=request.extraversion,
412
  agreeableness=request.agreeableness,
 
435
  p.occupation = request.occupation
436
  if request.background is not None:
437
  p.background = request.background
438
+ if request.gender is not None:
439
+ p.gender = request.gender
440
  if request.extraversion is not None:
441
  p.extraversion = max(1, min(10, request.extraversion))
442
  if request.agreeableness is not None:
src/soci/engine/simulation.py CHANGED
@@ -7,7 +7,7 @@ import logging
7
  import random
8
  from typing import Callable, Optional
9
 
10
- from soci.agents.agent import Agent, AgentAction
11
  from soci.agents.memory import MemoryType
12
  from soci.agents.persona import Persona, load_personas
13
  from soci.agents.generator import generate_personas
@@ -259,6 +259,9 @@ class Simulation:
259
  if result and isinstance(result, AgentAction):
260
  await self._execute_action(agent, result)
261
 
 
 
 
262
  # 6. Handle active conversations (skip in 50x mode)
263
  if not self._skip_llm_this_tick:
264
  conv_coros = []
@@ -455,16 +458,23 @@ class Simulation:
455
  )
456
 
457
  async def _handle_social_interactions(self, agents: list[Agent]) -> None:
458
- """Check if any idle co-located agents should start conversations."""
459
- # Hard cap: at most 2 simultaneous conversations to keep LLM calls low
460
- max_convos = 2
461
  if len(self.active_conversations) >= max_convos:
462
  return
463
 
 
 
 
464
  for agent in agents:
465
- if agent.is_busy or agent.is_player:
 
 
 
 
466
  continue
467
- # Check if already in a conversation
468
  in_conv = any(
469
  agent.id in c.participants
470
  for c in self.active_conversations.values()
@@ -472,11 +482,12 @@ class Simulation:
472
  if in_conv:
473
  continue
474
 
 
475
  others = [
476
  aid for aid in self.city.get_agents_at(agent.location)
477
  if aid != agent.id
478
  and aid in self.agents
479
- and not self.agents[aid].is_busy
480
  and not any(aid in c.participants for c in self.active_conversations.values())
481
  ]
482
  if not others:
@@ -487,6 +498,38 @@ class Simulation:
487
  await self._start_conversation(agent, self.agents[partner_id])
488
  break # One new conversation per tick max
489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  async def _start_conversation(self, initiator: Agent, target: Agent) -> None:
491
  """Start a conversation between two agents."""
492
  self._conversation_counter += 1
@@ -666,8 +709,6 @@ class Simulation:
666
  agents_list = list(self.agents.values())
667
 
668
  for agent in agents_list:
669
- if agent.is_player:
670
- continue
671
  loc = self.city.get_location(agent.location)
672
  if not loc:
673
  continue
@@ -685,6 +726,19 @@ class Simulation:
685
  if agent.partner_id and agent.partner_id != other_id:
686
  continue
687
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  # Attraction grows from positive interactions
689
  if rel.sentiment > 0.6 and rel.trust > 0.5:
690
  # Base attraction growth per tick
 
7
  import random
8
  from typing import Callable, Optional
9
 
10
+ from soci.agents.agent import Agent, AgentAction, AgentState
11
  from soci.agents.memory import MemoryType
12
  from soci.agents.persona import Persona, load_personas
13
  from soci.agents.generator import generate_personas
 
259
  if result and isinstance(result, AgentAction):
260
  await self._execute_action(agent, result)
261
 
262
+ # 5b. Player auto-sleep: put idle players to sleep at night
263
+ self._handle_player_sleep()
264
+
265
  # 6. Handle active conversations (skip in 50x mode)
266
  if not self._skip_llm_this_tick:
267
  conv_coros = []
 
458
  )
459
 
460
  async def _handle_social_interactions(self, agents: list[Agent]) -> None:
461
+ """Check if any co-located agents should start conversations."""
462
+ # Hard cap: at most 3 simultaneous conversations
463
+ max_convos = 3
464
  if len(self.active_conversations) >= max_convos:
465
  return
466
 
467
+ # States where an agent can initiate or join a conversation
468
+ _CONVERSABLE = {"idle", "eating", "relaxing", "working", "exercising", "shopping"}
469
+
470
  for agent in agents:
471
+ # Skip sleeping, moving, or agents already in conversation
472
+ if agent.state.value not in _CONVERSABLE:
473
+ continue
474
+ # Skip players (they initiate via the /player/talk API)
475
+ if agent.is_player:
476
  continue
477
+ # Skip if already in a conversation
478
  in_conv = any(
479
  agent.id in c.participants
480
  for c in self.active_conversations.values()
 
482
  if in_conv:
483
  continue
484
 
485
+ # Potential partners: anyone at same location who is also conversable
486
  others = [
487
  aid for aid in self.city.get_agents_at(agent.location)
488
  if aid != agent.id
489
  and aid in self.agents
490
+ and self.agents[aid].state.value in _CONVERSABLE
491
  and not any(aid in c.participants for c in self.active_conversations.values())
492
  ]
493
  if not others:
 
498
  await self._start_conversation(agent, self.agents[partner_id])
499
  break # One new conversation per tick max
500
 
501
+ def _handle_player_sleep(self) -> None:
502
+ """Auto-sleep idle players during sleeping hours, wake them in the morning."""
503
+ for agent in self.agents.values():
504
+ if not agent.is_player:
505
+ continue
506
+ if self.clock.is_sleeping_hours:
507
+ if not agent.is_busy and agent.state.value != "sleeping":
508
+ sleep_action = AgentAction(
509
+ type="sleep",
510
+ target=agent.persona.home_location,
511
+ detail=f"{agent.name} goes to sleep for the night",
512
+ duration_ticks=32, # ~8 hours
513
+ needs_satisfied={"energy": 0.9},
514
+ )
515
+ # Move home if needed (teleport β€” player is asleep)
516
+ home = agent.persona.home_location
517
+ if agent.location != home and self.city.get_location(home):
518
+ old_loc = self.city.get_location(agent.location)
519
+ new_loc = self.city.get_location(home)
520
+ if old_loc:
521
+ old_loc.remove_occupant(agent.id)
522
+ new_loc.add_occupant(agent.id)
523
+ agent.location = home
524
+ agent.start_action(sleep_action)
525
+ self._emit(f" {agent.name} goes to sleep for the night.")
526
+ else:
527
+ # Wake player if still sleeping past sleeping hours
528
+ if agent.state.value == "sleeping":
529
+ agent.state = AgentState.IDLE
530
+ agent.current_action = None
531
+ agent._action_ticks_remaining = 0
532
+
533
  async def _start_conversation(self, initiator: Agent, target: Agent) -> None:
534
  """Start a conversation between two agents."""
535
  self._conversation_counter += 1
 
709
  agents_list = list(self.agents.values())
710
 
711
  for agent in agents_list:
 
 
712
  loc = self.city.get_location(agent.location)
713
  if not loc:
714
  continue
 
726
  if agent.partner_id and agent.partner_id != other_id:
727
  continue
728
 
729
+ # Gender-based attraction: opposite genders attract (nonbinary attracted to all)
730
+ a_gender = agent.persona.gender
731
+ o_gender = other.persona.gender
732
+ attracted = (
733
+ a_gender == "unknown"
734
+ or a_gender == "nonbinary"
735
+ or o_gender == "unknown"
736
+ or o_gender == "nonbinary"
737
+ or a_gender != o_gender
738
+ )
739
+ if not attracted:
740
+ continue
741
+
742
  # Attraction grows from positive interactions
743
  if rel.sentiment > 0.6 and rel.trust > 0.5:
744
  # Base attraction growth per tick
web/index.html CHANGED
@@ -334,8 +334,21 @@
334
  <div><label>Name</label><input type="text" id="pe-name" placeholder="Your name"></div>
335
  <div><label>Age</label><input type="number" id="pe-age" min="16" max="90" value="30"></div>
336
  </div>
337
- <label>Occupation</label>
338
- <input type="text" id="pe-occupation" placeholder="e.g. Artist, Engineer, Chef">
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  <label>Background (how you describe yourself)</label>
340
  <textarea id="pe-background" rows="3" placeholder="A few sentences about yourself..."></textarea>
341
  <label>Extraversion: <span id="pe-extra-val" class="slider-val">5</span></label>
@@ -3140,6 +3153,8 @@ function openProfileEditor() {
3140
  document.getElementById('pe-name').value = agent.name || '';
3141
  document.getElementById('pe-age').value = agent.age || 30;
3142
  document.getElementById('pe-occupation').value = agent.occupation || '';
 
 
3143
  }
3144
  document.getElementById('profile-modal').style.display = 'flex';
3145
  }
@@ -3150,6 +3165,7 @@ async function saveProfile() {
3150
  token: playerToken,
3151
  name: document.getElementById('pe-name').value.trim(),
3152
  age: parseInt(document.getElementById('pe-age').value)||30,
 
3153
  occupation: document.getElementById('pe-occupation').value.trim(),
3154
  background: document.getElementById('pe-background').value.trim(),
3155
  extraversion: parseInt(document.getElementById('pe-extraversion').value),
 
334
  <div><label>Name</label><input type="text" id="pe-name" placeholder="Your name"></div>
335
  <div><label>Age</label><input type="number" id="pe-age" min="16" max="90" value="30"></div>
336
  </div>
337
+ <div class="modal-row">
338
+ <div style="flex:1">
339
+ <label>Gender</label>
340
+ <select id="pe-gender" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px">
341
+ <option value="unknown">Prefer not to say</option>
342
+ <option value="male">Male</option>
343
+ <option value="female">Female</option>
344
+ <option value="nonbinary">Non-binary</option>
345
+ </select>
346
+ </div>
347
+ <div style="flex:1">
348
+ <label>Occupation</label>
349
+ <input type="text" id="pe-occupation" placeholder="e.g. Artist, Engineer, Chef" style="width:100%">
350
+ </div>
351
+ </div>
352
  <label>Background (how you describe yourself)</label>
353
  <textarea id="pe-background" rows="3" placeholder="A few sentences about yourself..."></textarea>
354
  <label>Extraversion: <span id="pe-extra-val" class="slider-val">5</span></label>
 
3153
  document.getElementById('pe-name').value = agent.name || '';
3154
  document.getElementById('pe-age').value = agent.age || 30;
3155
  document.getElementById('pe-occupation').value = agent.occupation || '';
3156
+ const gSel = document.getElementById('pe-gender');
3157
+ gSel.value = agent.gender || 'unknown';
3158
  }
3159
  document.getElementById('profile-modal').style.display = 'flex';
3160
  }
 
3165
  token: playerToken,
3166
  name: document.getElementById('pe-name').value.trim(),
3167
  age: parseInt(document.getElementById('pe-age').value)||30,
3168
+ gender: document.getElementById('pe-gender').value,
3169
  occupation: document.getElementById('pe-occupation').value.trim(),
3170
  background: document.getElementById('pe-background').value.trim(),
3171
  extraversion: parseInt(document.getElementById('pe-extraversion').value),