File size: 27,181 Bytes
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bea6321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bea6321
 
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bea6321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e801152
4784d87
 
 
 
 
 
 
 
 
 
 
e801152
 
4784d87
 
 
 
 
 
 
bea6321
 
 
 
 
 
 
 
 
 
4784d87
 
bea6321
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c09fb8
 
 
 
 
4784d87
 
7c09fb8
4784d87
 
 
7c09fb8
bea6321
 
4784d87
7c09fb8
 
 
4784d87
 
 
 
 
 
7c09fb8
 
 
 
 
 
 
4784d87
7c09fb8
 
 
 
 
 
4784d87
 
 
 
 
 
bea6321
 
 
4784d87
bea6321
 
 
 
4784d87
bea6321
 
4784d87
bea6321
4784d87
bea6321
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bea6321
 
 
 
 
4784d87
bea6321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e801152
bea6321
 
 
 
 
 
 
 
 
 
 
 
e801152
bea6321
4784d87
 
bea6321
4784d87
 
 
e801152
 
bea6321
e801152
bea6321
4784d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Daily routine system β€” deterministic schedules based on persona traits."""

from __future__ import annotations

import random
from dataclasses import dataclass, field
from typing import Optional, TYPE_CHECKING

from soci.agents.generator import (
    EVENING_SHIFT_JOBS, STUDENT_OCCUPATIONS, RETIRED_OCCUPATIONS,
)

if TYPE_CHECKING:
    from soci.agents.persona import Persona


@dataclass
class RoutineSlot:
    """A single block in an agent's daily schedule."""

    hour: int           # Start hour (0-23)
    minute: int         # Start minute (0 or 15 or 30 or 45)
    action_type: str    # sleep, eat, work, move, relax, exercise, etc.
    target_location: str  # Where to do it
    duration_ticks: int   # How many 15-min ticks
    detail: str         # Human-readable description
    needs_satisfied: dict[str, float] = field(default_factory=dict)

    @property
    def end_minutes(self) -> int:
        """Total minutes from midnight when this slot ends."""
        return self.hour * 60 + self.minute + self.duration_ticks * 15


class DailyRoutine:
    """A deterministic daily schedule built from persona traits.

    The routine is fully derived from the persona, so it doesn't need
    serialization β€” it's rebuilt on load.
    """

    def __init__(self, persona: Persona, is_weekend: bool = False) -> None:
        self.persona_id = persona.id
        self.slots: list[RoutineSlot] = []
        self.wake_hour: int = 7
        self.sleep_hour: int = 22
        self._rng = random.Random(hash(persona.id))  # Deterministic per persona
        self._build_routine(persona, is_weekend)

    def get_action_for_time(self, hour: int, minute: int) -> Optional[RoutineSlot]:
        """Return the routine slot active at the given time, or None."""
        time_mins = hour * 60 + minute
        for slot in self.slots:
            slot_start = slot.hour * 60 + slot.minute
            slot_end = slot_start + slot.duration_ticks * 15
            if slot_start <= time_mins < slot_end:
                return slot
        return None

    def is_awake_at(self, hour: int) -> bool:
        """Whether this agent should be awake at the given hour."""
        if self.wake_hour <= self.sleep_hour:
            return self.wake_hour <= hour < self.sleep_hour
        else:
            # Wraps past midnight (e.g. wake=14, sleep=2)
            return hour >= self.wake_hour or hour < self.sleep_hour

    def _jitter(self, base_minutes: int, spread: int = 30) -> tuple[int, int]:
        """Add random jitter to a time. Returns (hour, minute) snapped to 15-min."""
        offset = self._rng.randint(-spread, spread)
        total = max(0, min(23 * 60 + 45, base_minutes + offset))
        # Snap to 15-minute intervals
        total = (total // 15) * 15
        return total // 60, total % 60

    def _build_routine(self, persona: Persona, is_weekend: bool) -> None:
        """Build the full daily schedule from persona traits."""
        c = persona.conscientiousness
        e = persona.extraversion
        home = persona.home_location
        work = persona.work_location

        is_evening_shift = persona.occupation.lower() in EVENING_SHIFT_JOBS
        is_student = persona.occupation.lower() in STUDENT_OCCUPATIONS
        is_retired = persona.occupation.lower() in RETIRED_OCCUPATIONS

        # --- Determine wake/sleep times ---
        if is_evening_shift:
            base_wake = 10 * 60  # 10:00
            base_sleep = 2 * 60  # 02:00 (next day, treated as 26:00)
        elif is_student:
            base_wake = 8 * 60 + 30  # 08:30
            base_sleep = 23 * 60 + 30
        elif is_retired:
            base_wake = 7 * 60  # 07:00
            base_sleep = 21 * 60 + 30
        else:
            # High conscientiousness β†’ early riser
            base_wake = 6 * 60 + (10 - c) * 15  # 6:00 (c=10) to 7:15 (c=5) to 8:15+ (c=1)
            base_sleep = 22 * 60

        wake_h, wake_m = self._jitter(base_wake, 20)
        sleep_h, sleep_m = self._jitter(base_sleep, 30)
        self.wake_hour = wake_h
        self.sleep_hour = sleep_h

        if is_weekend or is_retired:
            self._build_leisure_day(persona, home, work, wake_h, wake_m, sleep_h, sleep_m,
                                    is_retired)
        elif is_evening_shift:
            self._build_evening_shift(persona, home, work, wake_h, wake_m, sleep_h, sleep_m)
        else:
            self._build_work_day(persona, home, work, wake_h, wake_m, sleep_h, sleep_m,
                                 is_student)

    def _add(self, hour: int, minute: int, action: str, location: str,
             ticks: int, detail: str, needs: dict[str, float] | None = None) -> int:
        """Add a slot and return the end time in minutes."""
        self.slots.append(RoutineSlot(
            hour=hour, minute=minute, action_type=action,
            target_location=location, duration_ticks=ticks, detail=detail,
            needs_satisfied=needs or {},
        ))
        return hour * 60 + minute + ticks * 15

    def _build_work_day(self, persona: Persona, home: str, work: str,
                        wake_h: int, wake_m: int, sleep_h: int, sleep_m: int,
                        is_student: bool) -> None:
        """Standard 9-to-5 (ish) work day."""
        t = wake_h * 60 + wake_m

        # Wake up + morning routine
        h, m = t // 60, t % 60
        t = self._add(h, m, "relax", home, 2, "Morning routine β€” getting ready",
                       {"comfort": 0.1, "energy": 0.05})

        # Morning exercise for active personas (30% chance if conscientious or extravert)
        if (persona.conscientiousness >= 7 or persona.extraversion >= 7) and self._rng.random() < 0.3:
            morning_spot = self._rng.choice(["park", "park", "gym", "sports_field"])
            morning_exercise = {
                "park": self._rng.choice(["Morning jog in the park", "Early walk in the park"]),
                "gym": "Morning gym session",
                "sports_field": self._rng.choice(["Morning run at the sports field",
                                                   "Early workout at the sports field"]),
            }.get(morning_spot, f"Morning exercise at {morning_spot}")
            h, m = t // 60, t % 60
            t = self._add(h, m, "move", morning_spot, 1, f"Heading to {morning_spot}",
                           {})
            h, m = t // 60, t % 60
            t = self._add(h, m, "exercise", morning_spot, 2, morning_exercise,
                           {"fun": 0.1, "energy": -0.05})
            h, m = t // 60, t % 60
            t = self._add(h, m, "move", home, 1, "Back home to freshen up",
                           {})

        # Breakfast
        h, m = t // 60, t % 60
        t = self._add(h, m, "eat", home, 2, "Having breakfast at home",
                       {"hunger": 0.4})

        # Commute to work
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", work, 1, f"Commuting to {work}",
                       {})

        # Morning work block
        work_label = "Studying" if is_student else "Working"
        morning_ticks = self._rng.randint(7, 10)
        h, m = t // 60, t % 60
        t = self._add(h, m, "work", work, morning_ticks,
                       f"{work_label} β€” morning block",
                       {"purpose": 0.3})

        # Lunch β€” pick a food place, park, or stay at work
        food_places = ["cafe", "restaurant", "grocery", "bakery", "park", "park"]
        lunch_spot = self._rng.choice(food_places)
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", lunch_spot, 1, f"Walking to lunch at {lunch_spot}",
                       {})
        h, m = t // 60, t % 60
        t = self._add(h, m, "eat", lunch_spot, 2, "Lunch break",
                       {"hunger": 0.5, "social": 0.1})

        # Walk back to work
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", work, 1, f"Walking back to {work}",
                       {})

        # Afternoon work block
        afternoon_ticks = self._rng.randint(6, 9)
        h, m = t // 60, t % 60
        t = self._add(h, m, "work", work, afternoon_ticks,
                       f"{work_label} β€” afternoon block",
                       {"purpose": 0.3})

        # Post-work exercise for active personas (conscientiousness >= 6 or extraversion >= 7)
        if (persona.conscientiousness >= 6 or persona.extraversion >= 7) and self._rng.random() < 0.4:
            exercise_spot = self._rng.choice(["gym", "park", "sports_field", "park"])
            exercise_details = {
                "gym": "Post-work gym session",
                "park": self._rng.choice(["Jogging in the park", "Evening walk in the park",
                                          "Stretching and walking in the park"]),
                "sports_field": self._rng.choice(["Playing pickup soccer after work",
                                                   "Evening run at the sports field",
                                                   "Shooting hoops at the sports field"]),
            }
            h, m = t // 60, t % 60
            t = self._add(h, m, "move", exercise_spot, 1, f"Heading to {exercise_spot}",
                           {})
            h, m = t // 60, t % 60
            t = self._add(h, m, "exercise", exercise_spot, self._rng.randint(2, 4),
                           exercise_details.get(exercise_spot, f"Exercising at {exercise_spot}"),
                           {"fun": 0.2, "energy": -0.1})

        # Commute home
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", home, 1, "Heading home",
                       {})

        # Dinner
        h, m = t // 60, t % 60
        t = self._add(h, m, "eat", home, 2, "Having dinner at home",
                       {"hunger": 0.5})

        # Evening entertainment
        t = self._add_evening_block(persona, home, t, sleep_h, sleep_m)

        # Sleep
        h, m = t // 60, t % 60
        # Sleep until wake time next day β€” approximate with 20 ticks
        sleep_ticks = max(4, ((24 * 60 - t) + self.wake_hour * 60) // 15)
        sleep_ticks = min(sleep_ticks, 32)  # Cap at 8 hours
        self._add(h, m, "sleep", home, sleep_ticks, "Sleeping",
                  {"energy": 0.8})

    def _build_evening_shift(self, persona: Persona, home: str, work: str,
                             wake_h: int, wake_m: int,
                             sleep_h: int, sleep_m: int) -> None:
        """Evening shift workers: bartenders, chefs, etc."""
        t = wake_h * 60 + wake_m

        # Late morning routine
        h, m = t // 60, t % 60
        t = self._add(h, m, "relax", home, 2, "Late morning routine",
                       {"comfort": 0.1, "energy": 0.05})

        # Brunch
        h, m = t // 60, t % 60
        t = self._add(h, m, "eat", home, 2, "Having brunch",
                       {"hunger": 0.4})

        # Free time before shift
        t = self._add_leisure_block(persona, home, t, min(t + 4 * 15, 15 * 60))

        # Commute to work
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", work, 1, f"Heading to {work} for shift",
                       {})

        # Evening shift (long)
        shift_ticks = self._rng.randint(12, 16)
        h, m = t // 60, t % 60
        t = self._add(h, m, "work", work, shift_ticks,
                       f"Working the evening shift at {work}",
                       {"purpose": 0.4})

        # Head home
        h, m = (t // 60) % 24, t % 60
        t = self._add(h, m, "move", home, 1, "Heading home after shift",
                       {})

        # Late snack + sleep
        h, m = (t // 60) % 24, t % 60
        t = self._add(h, m, "eat", home, 1, "Late night snack",
                       {"hunger": 0.3})
        h, m = (t // 60) % 24, t % 60
        self._add(h, m, "sleep", home, 20, "Sleeping",
                  {"energy": 0.8})

    def _build_leisure_day(self, persona: Persona, home: str, work: str,
                           wake_h: int, wake_m: int, sleep_h: int, sleep_m: int,
                           is_retired: bool) -> None:
        """Weekend or retired day β€” no work, loose schedule, more entertainment."""
        t = wake_h * 60 + wake_m
        e = persona.extraversion
        o = persona.openness

        # Sleep in extra on weekends (not retired β€” they keep regular hours)
        if not is_retired:
            t += self._rng.randint(30, 60)
            # Snap to 15-min
            t = (t // 15) * 15

        # Slow morning
        h, m = t // 60, t % 60
        t = self._add(h, m, "relax", home, 3, "Lazy morning β€” sleeping in and lounging",
                       {"comfort": 0.25, "energy": 0.15})

        # Late breakfast / brunch
        brunch_out = e >= 6 and self._rng.random() < 0.5
        if brunch_out:
            brunch_spot = self._rng.choice(["cafe", "restaurant"])
            h, m = t // 60, t % 60
            t = self._add(h, m, "move", brunch_spot, 1, f"Going to {brunch_spot} for brunch",
                           {})
            h, m = t // 60, t % 60
            t = self._add(h, m, "eat", brunch_spot, 3, f"Enjoying brunch at {brunch_spot}",
                           {"hunger": 0.5, "social": 0.2})
            h, m = t // 60, t % 60
            t = self._add(h, m, "move", home, 1, "Heading home",
                           {})
        else:
            h, m = t // 60, t % 60
            t = self._add(h, m, "eat", home, 2, "Leisurely breakfast at home",
                           {"hunger": 0.4})

        # Morning/early afternoon activity β€” longer and more varied than weekdays
        morning_end = t + self._rng.randint(8, 14) * 15
        t = self._add_leisure_block(persona, home, t, morning_end)

        # Lunch β€” target ~12:30-13:30
        lunch_target = 12 * 60 + 30
        if t < lunch_target:
            gap_ticks = (lunch_target - t) // 15
            if gap_ticks > 0:
                h, m = t // 60, t % 60
                t = self._add(h, m, "relax", home, gap_ticks, "Chilling at home",
                               {"comfort": 0.1, "fun": 0.05})

        lunch_spot = self._rng.choice(["cafe", "restaurant", "park", "bakery", "town_square"])
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", lunch_spot, 1, f"Heading to {lunch_spot}",
                       {})
        h, m = t // 60, t % 60
        t = self._add(h, m, "eat", lunch_spot, 2, "Lunch out",
                       {"hunger": 0.5, "social": 0.15})

        # Afternoon β€” extroverts do social things, introverts do solo things
        if e >= 6:
            # Social afternoon: visit multiple places
            afternoon_places = self._rng.sample(
                ["park", "cafe", "bar", "gym", "library", "cinema",
                 "town_square", "sports_field"],
                k=min(2, self._rng.randint(1, 2)),
            )
            for place in afternoon_places:
                h, m = t // 60, t % 60
                t = self._add(h, m, "move", place, 1, f"Going to {place}",
                               {})
                act_ticks = self._rng.randint(3, 6)
                act_type = "exercise" if place in ("gym", "sports_field") else "relax"
                if place == "park" and self._rng.random() < 0.4:
                    act_type = "exercise"
                act_detail = {
                    "park": self._rng.choice(["Walking around the park", "Jogging in the park",
                                               "Relaxing in the park"]),
                    "sports_field": self._rng.choice(["Playing soccer", "Shooting hoops",
                                                       "Running laps", "Playing frisbee"]),
                    "gym": "Working out",
                }.get(place, f"Hanging out at {place}")
                h, m = t // 60, t % 60
                t = self._add(h, m, act_type, place, act_ticks,
                               act_detail,
                               {"social": 0.2, "fun": 0.25})
        else:
            # Quiet afternoon
            afternoon_end = t + self._rng.randint(8, 16) * 15
            t = self._add_leisure_block(persona, home, t, afternoon_end)

        # Dinner β€” target ~18:00-19:30 (later on weekends)
        dinner_target = 18 * 60 + self._rng.randint(0, 6) * 15
        if t < dinner_target:
            gap_ticks = (dinner_target - t) // 15
            if gap_ticks > 0:
                h, m = t // 60, t % 60
                t = self._add(h, m, "relax", home, gap_ticks, "Relaxing at home",
                               {"comfort": 0.15, "fun": 0.1})

        # Weekend dinner β€” more likely to eat out
        dinner_out = e >= 5 or self._rng.random() < 0.3
        if dinner_out:
            dinner_spot = self._rng.choice(["restaurant", "bar"])
            h, m = t // 60, t % 60
            t = self._add(h, m, "move", dinner_spot, 1, f"Going to {dinner_spot} for dinner",
                           {})
            h, m = t // 60, t % 60
            t = self._add(h, m, "eat", dinner_spot, 3, f"Dinner at {dinner_spot}",
                           {"hunger": 0.5, "social": 0.2})
        else:
            h, m = t // 60, t % 60
            t = self._add(h, m, "eat", home, 2, "Dinner at home",
                           {"hunger": 0.5})

        # Weekend evening β€” extended entertainment, later bedtime
        weekend_sleep_h = min(23, sleep_h + 1)  # Stay up later on weekends
        weekend_sleep_m = sleep_m
        t = self._add_evening_block(persona, home, t, weekend_sleep_h, weekend_sleep_m)

        # Sleep
        h, m = t // 60, t % 60
        self._add(h, m, "sleep", home, 28, "Sleeping",
                  {"energy": 0.8})

    def _add_evening_block(self, persona: Persona, home: str,
                           t: int, sleep_h: int, sleep_m: int) -> int:
        """Add evening entertainment filling the full period until sleep.

        Extroverts go out then wind down; introverts stay home the whole time.
        Returns updated time in minutes.
        """
        e = persona.extraversion
        sleep_t = sleep_h * 60 + sleep_m
        if sleep_t <= t:
            return t

        if e >= 6:
            # Extroverts: go out, stay until ~30-45 min before sleep, then come home
            venue = self._rng.choice(["bar", "restaurant", "park", "cinema",
                                      "town_square", "sports_field", "park"])
            h, m = t // 60, t % 60
            t = self._add(h, m, "move", venue, 1, f"Heading to {venue}", {})
            wind_down_start = sleep_t - self._rng.randint(2, 3) * 15
            ent_ticks = max(0, min((wind_down_start - t) // 15, self._rng.randint(4, 8)))
            if ent_ticks > 0:
                h, m = t // 60, t % 60
                t = self._add(h, m, "relax", venue, ent_ticks,
                               f"Socializing at {venue}",
                               {"fun": 0.3, "social": 0.3})
            # Head home
            if t < sleep_t:
                h, m = t // 60, t % 60
                t = self._add(h, m, "move", home, 1, "Heading home", {})

        # Wind down at home until sleep (covers all remaining time)
        wind_down_ticks = max(0, (sleep_t - t) // 15)
        if wind_down_ticks > 0:
            h, m = t // 60, t % 60
            detail = self._rng.choice([
                "Reading before bed", "Watching TV", "Browsing the internet",
                "Journaling", "Listening to music", "Winding down at home",
            ])
            t = self._add(h, m, "relax", home, wind_down_ticks, detail,
                           {"comfort": 0.25, "fun": 0.15, "energy": 0.05})

        return t

    def _add_leisure_block(self, persona: Persona, home: str,
                           t: int, end_t: int) -> int:
        """Fill a leisure period with activities based on personality."""
        # Base activities available to everyone
        activities = ["park", "park"]  # Park is always a strong option

        if persona.extraversion >= 6:
            activities.extend(["cafe", "gym", "town_square", "sports_field",
                               "sports_field", "park", "bar"])
        elif persona.extraversion >= 4:
            activities.extend(["cafe", "park", "sports_field", "town_square"])
        else:
            activities.extend(["library", "church", "park"])

        if persona.conscientiousness >= 6:
            activities.extend(["gym", "sports_field"])
        if persona.openness >= 6:
            activities.extend(["library", "park", "cinema", "town_square"])

        dest = self._rng.choice(activities)
        available_ticks = max(0, (end_t - t) // 15)

        if available_ticks <= 1:
            return t

        # Move to destination
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", dest, 1, f"Going to {dest}",
                       {})
        available_ticks -= 1

        # Activity there
        act_ticks = min(available_ticks - 1, self._rng.randint(2, max(3, available_ticks - 1)))
        if act_ticks > 0:
            act_type = "exercise" if dest in ("gym", "sports_field") else "relax"
            # Park can be exercise too (jogging, walking)
            if dest == "park" and self._rng.random() < 0.5:
                act_type = "exercise"

            act_detail = {
                "park": self._rng.choice([
                    "Taking a walk in the park", "Jogging through the park",
                    "Strolling along the park paths", "Sitting on a bench in the park",
                    "Walking the trails at Willow Park", "Enjoying nature in the park",
                    "Doing yoga in the park", "Reading on a park bench",
                ]),
                "cafe": self._rng.choice([
                    "Hanging out at the cafe", "Having coffee at the cafe",
                    "Working on a laptop at the cafe", "Chatting at the cafe",
                ]),
                "gym": self._rng.choice([
                    "Working out at the gym", "Lifting weights at the gym",
                    "Doing cardio at the gym", "Fitness class at the gym",
                ]),
                "library": self._rng.choice([
                    "Reading at the library", "Browsing books at the library",
                    "Studying at the library", "Quiet time at the library",
                ]),
                "cinema": "Watching a movie",
                "town_square": self._rng.choice([
                    "People-watching at the square", "Hanging out at the square",
                    "Sitting by the fountain in town square",
                ]),
                "sports_field": self._rng.choice([
                    "Playing soccer at the sports field",
                    "Shooting hoops at the sports field",
                    "Playing catch at the sports field",
                    "Running laps at the sports field",
                    "Playing frisbee at the sports field",
                    "Doing drills at the sports field",
                ]),
                "church": "Quiet time at the church",
                "bar": "Having a drink at the bar",
            }.get(dest, f"Spending time at {dest}")
            needs = {
                "park": {"fun": 0.2, "comfort": 0.15},
                "cafe": {"social": 0.2, "fun": 0.1},
                "gym": {"energy": -0.1, "fun": 0.2},
                "library": {"fun": 0.15, "comfort": 0.1},
                "cinema": {"fun": 0.3, "social": 0.1},
                "town_square": {"social": 0.2, "fun": 0.15},
                "sports_field": {"fun": 0.3, "social": 0.15, "energy": -0.1},
                "church": {"comfort": 0.2, "purpose": 0.1},
                "bar": {"social": 0.2, "fun": 0.15},
            }.get(dest, {"fun": 0.1})
            h, m = t // 60, t % 60
            t = self._add(h, m, act_type, dest, act_ticks, act_detail, needs)

        # Head back home
        h, m = t // 60, t % 60
        t = self._add(h, m, "move", home, 1, "Heading home",
                       {})

        return t


def check_motivation_override(
    slot: RoutineSlot,
    needs: "NeedsState",
    mood: float,
    extraversion: int,
    conscientiousness: int,
) -> Optional[RoutineSlot]:
    """Check if an agent's needs or mood are strong enough to override their routine.

    Returns an alternative RoutineSlot if motivated to deviate, or None to follow routine.
    High conscientiousness agents are harder to derail. Low mood or critical needs
    can force deviation.
    """
    from soci.agents.needs import NeedsState  # avoid circular at module level

    # Conscientiousness determines resistance to deviation (0.3 to 0.9)
    resistance = 0.3 + conscientiousness * 0.06

    # Check critical needs β€” these override regardless of personality
    if needs.hunger < 0.15 and slot.action_type not in ("eat", "move", "sleep"):
        return RoutineSlot(
            hour=slot.hour, minute=slot.minute, action_type="eat",
            target_location=slot.target_location, duration_ticks=2,
            detail="Desperately need to eat β€” skipping routine",
            needs_satisfied={"hunger": 0.5},
        )

    if needs.energy < 0.1 and slot.action_type not in ("sleep", "relax"):
        return RoutineSlot(
            hour=slot.hour, minute=slot.minute, action_type="sleep",
            target_location=slot.target_location, duration_ticks=4,
            detail="Exhausted β€” need to rest",
            needs_satisfied={"energy": 0.5},
        )

    # Social need override β€” extroverts are more driven by social needs
    social_threshold = 0.25 if extraversion >= 7 else 0.15
    if (needs.social < social_threshold and slot.action_type in ("work", "relax")
            and random.random() > resistance):
        return RoutineSlot(
            hour=slot.hour, minute=slot.minute, action_type="move",
            target_location="cafe", duration_ticks=1,
            detail="Feeling lonely β€” heading somewhere social",
            needs_satisfied={},
        )

    # Very bad mood can derail work (skip to relax)
    if (mood < -0.5 and slot.action_type == "work"
            and random.random() > resistance):
        return RoutineSlot(
            hour=slot.hour, minute=slot.minute, action_type="relax",
            target_location=slot.target_location, duration_ticks=slot.duration_ticks,
            detail="Not feeling up to work β€” taking it easy",
            needs_satisfied={"comfort": 0.2, "fun": 0.1},
        )

    # Fun deprivation β€” spontaneous entertainment
    if (needs.fun < 0.15 and slot.action_type == "work"
            and random.random() > resistance + 0.1):
        return RoutineSlot(
            hour=slot.hour, minute=slot.minute, action_type="relax",
            target_location="park", duration_ticks=2,
            detail="Need a break β€” going for a walk",
            needs_satisfied={"fun": 0.2, "comfort": 0.1},
        )

    return None


def build_routine(persona: Persona, day: int) -> DailyRoutine:
    """Build a daily routine for a persona. Weekend every 6th-7th day."""
    is_weekend = (day % 7) in (6, 0)  # Days 6 and 7 are weekends
    return DailyRoutine(persona, is_weekend=is_weekend)