-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathbehavior_manager.py
More file actions
814 lines (710 loc) · 34.9 KB
/
Copy pathbehavior_manager.py
File metadata and controls
814 lines (710 loc) · 34.9 KB
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
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
"""Behavior manager - lazy-loads and manages behavior module lifecycle."""
import math
import random
import gc
import sys
class BehaviorManager:
"""Central registry for behavior loading, selection, and module lifecycle.
Behaviors are lazy-loaded on demand and unloaded after completion to free
memory. The manager holds all can_trigger and priority logic so behavior
modules don't need cross-imports just for eligibility checks.
"""
# Registry: name -> (module_path, class_name)
_REGISTRY = {
'idle': ('entities.behaviors.idle', 'IdleBehavior'),
'sleeping': ('entities.behaviors.sleeping', 'SleepingBehavior'),
'napping': ('entities.behaviors.napping', 'NappingBehavior'),
'stretching': ('entities.behaviors.stretching', 'StretchingBehavior'),
'kneading': ('entities.behaviors.kneading', 'KneadingBehavior'),
'lounging': ('entities.behaviors.lounging', 'LoungeingBehavior'),
'investigating': ('entities.behaviors.investigating', 'InvestigatingBehavior'),
'observing': ('entities.behaviors.observing', 'ObservingBehavior'),
'chattering': ('entities.behaviors.chattering', 'ChatteringBehavior'),
'zoomies': ('entities.behaviors.zoomies', 'ZoomiesBehavior'),
'vocalizing': ('entities.behaviors.vocalizing', 'VocalizingBehavior'),
'self_grooming': ('entities.behaviors.self_grooming', 'SelfGroomingBehavior'),
'being_groomed': ('entities.behaviors.being_groomed', 'BeingGroomedBehavior'),
'hunting': ('entities.behaviors.hunting', 'HuntingBehavior'),
'gift_bringing': ('entities.behaviors.gift_bringing', 'GiftBringingBehavior'),
'pacing': ('entities.behaviors.pacing', 'PacingBehavior'),
'sulking': ('entities.behaviors.sulking', 'SulkingBehavior'),
'mischief': ('entities.behaviors.mischief', 'MischiefBehavior'),
'hiding': ('entities.behaviors.hiding', 'HidingBehavior'),
'training': ('entities.behaviors.training', 'TrainingBehavior'),
'playing': ('entities.behaviors.playing', 'PlayingBehavior'),
'affection': ('entities.behaviors.affection', 'AffectionBehavior'),
'attention': ('entities.behaviors.attention', 'AttentionBehavior'),
'eating': ('entities.behaviors.eating', 'EatingBehavior'),
'startled': ('entities.behaviors.startled', 'StartledBehavior'),
'meandering': ('entities.behaviors.meandering', 'MeanderingBehavior'),
'go_to': ('entities.behaviors.go_to', 'GoToBehavior'),
'hearing': ('entities.behaviors.hearing', 'HearingBehavior'),
'greeting': ('entities.behaviors.greeting', 'GreetingBehavior'),
}
# Outdoor scenes for sickness weather accumulation.
_SICK_OUTDOOR = frozenset(('outside', 'treehouse'))
# Behaviors blocked from auto-selection by sickness tier.
# Each set is additive — clearly sick also excludes everything in mild.
_SICK_MILD_BLOCKED = frozenset((
'zoomies', 'mischief', 'hunting', 'investigating', 'observing', 'playing',
))
_SICK_CLEAR_BLOCKED = frozenset((
'lounging', 'self_grooming', 'pacing', 'vocalizing', 'hiding',
))
# Ordered tuple of auto-selectable behavior names. Built once at class definition;
# can_trigger_<name> and priority_<name> are looked up via getattr at runtime.
_AUTO_SELECT_NAMES = (
'sleeping', 'napping', 'zoomies', 'vocalizing', 'hunting', 'playing',
'investigating', 'observing', 'self_grooming', 'stretching', 'pacing',
'sulking', 'mischief', 'hiding', 'lounging', 'startled',
)
def __init__(self, character):
self._character = character
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def stop_current(self):
"""Stop the active behavior and unload its module.
Called when the scene is exited so the cached scene's character
doesn't keep a behavior module pinned in sys.modules.
"""
if self._character.current_behavior and self._character.current_behavior.active:
old_module = type(self._character.current_behavior).__module__
self._character.current_behavior.stop(completed=False)
self._unload_module(old_module)
def trigger(self, name, **kwargs):
"""Player-initiated behavior trigger — interrupts the current behavior."""
if self._character.current_behavior and self._character.current_behavior.active:
old_module = type(self._character.current_behavior).__module__
self._character.current_behavior.stop(completed=False)
# Unload old module before loading new one: stop(completed=False) returns
# to behavior_manager.py, so the old module has no live stack frames.
# Running gc.collect() here gives the new module the best heap conditions.
new_module_path = self._REGISTRY.get(name, (old_module,))[0]
if old_module != new_module_path:
self._unload_module(old_module)
self._load_and_start(name, **kwargs)
def advance(self, name, kwargs, context):
"""Chain to the next behavior after natural completion.
Called from base.stop(completed=True). If name is None, auto-selects
based on context stats.
"""
if context:
cb = self._character.current_behavior
completing = getattr(cb, '_behavior_name', None) if cb else None
if completing:
context.record_behavior(completing)
if completing not in ('sleeping', 'napping'):
self._apply_sickness_accumulation(context)
if name is None:
if context and getattr(context, 'pending_wake_greeting', False):
context.pending_wake_greeting = False
wake_name = self._pick_wake_greeting(context)
if wake_name:
print('[WakeReact] Greeting: %s' % wake_name)
self._load_and_start(wake_name)
return
name, kwargs = self._auto_select(context)
if name is None:
name = 'idle'
kwargs = {}
self._load_and_start(name, **kwargs)
# ------------------------------------------------------------------
# Module lifecycle
# ------------------------------------------------------------------
def resume_prior_behavior(self):
"""Restart the prior behavior on scene re-entry, or idle if it was an interaction.
Interaction behaviors (triggered by the player, not autonomous) should not
be resumed when re-entering a scene — fall back to idle instead.
"""
_INTERACTION_BEHAVIORS = frozenset((
'affection', 'attention', 'being_groomed', 'eating',
'playing', 'gift_bringing', 'chattering', 'go_to', 'hearing',
))
ctx = self._character.context
prior = ctx.current_behavior_name if ctx else None
if prior and prior not in _INTERACTION_BEHAVIORS:
self.trigger(prior)
else:
self.trigger('idle')
def _load_and_start(self, name, **kwargs):
"""Load a behavior module, instantiate it, and start it."""
# Reset any per-behavior draw offset and bed flag before starting a new behavior.
self._character.draw_y_offset = 0
ctx = self._character.context
if ctx:
ctx.in_cat_bed = False
# In the bedroom, sleep-type behaviors may be redirected to walk to the
# cat bed first. If the cat is already near the bed, apply the lift offset.
if name in self._BED_SLEEP_BEHAVIORS:
name, kwargs = self._maybe_redirect_to_bed(name, kwargs)
if name in self._BED_SLEEP_BEHAVIORS:
cat_bed_x = getattr(ctx, 'cat_bed_x', None) if ctx else None
if cat_bed_x is not None and abs(self._character.x - cat_bed_x) < 8:
self._character.draw_y_offset = -4
ctx.in_cat_bed = True
if name not in self._REGISTRY:
print(f"\033[31mUnknown behavior: {name}, falling back to idle\033[0m")
name = 'idle'
kwargs = {}
module_path, class_name = self._REGISTRY[name]
ctx = self._character.context
if ctx:
ctx.current_behavior_name = name
gc.collect()
try:
mod = __import__(module_path, None, None, [class_name])
except MemoryError:
if name != 'idle':
print(f"\033[31mOOM loading {name}, falling back to idle\033[0m")
name = 'idle'
kwargs = {}
module_path, class_name = self._REGISTRY[name]
if ctx:
ctx.current_behavior_name = name
gc.collect()
mod = __import__(module_path, None, None, [class_name])
# Nothing will work reliably at this memory level — save and reboot.
if ctx:
ctx.save()
import machine
machine.reset()
else:
raise
cls = getattr(mod, class_name)
behavior = cls(self._character)
behavior._behavior_name = name
self._character.current_behavior = behavior
behavior.start(**kwargs)
# Behaviors that should walk to the cat bed before starting (when in bedroom).
_BED_SLEEP_BEHAVIORS = frozenset(('sleeping', 'napping', 'lounging'))
_BED_REDIRECT_CHANCE = 0.6
def _maybe_redirect_to_bed(self, name, kwargs):
"""If a sleep-type behavior triggers in the bedroom, sometimes walk to the
cat bed first via go_to, then chain to the original behavior on arrival."""
ctx = self._character.context
if not ctx:
return name, kwargs
cat_bed_x = getattr(ctx, 'cat_bed_x', None)
if cat_bed_x is None:
return name, kwargs
if abs(self._character.x - cat_bed_x) < 8:
return name, kwargs # already at (or in) the bed
if random.random() > self._BED_REDIRECT_CHANCE:
return name, kwargs # rolled to skip
return 'go_to', {'target_x': cat_bed_x, 'next_behavior': name, 'next_kwargs': kwargs}
# Modules kept permanently in sys.modules to avoid repeated import spikes.
# idle cycles on every behavior transition so pinning it eliminates the most
# frequent source of fragmentation.
_PINNED_MODULES = frozenset((
'entities.behaviors.idle',
))
def _unload_module(self, module_path):
"""Remove a behavior module from sys.modules and trigger GC.
Safe to call while still executing code from that module — the call
stack's frame reference keeps the code object alive until return.
"""
if module_path in self._PINNED_MODULES:
return
if module_path in sys.modules:
del sys.modules[module_path]
gc.collect()
# ------------------------------------------------------------------
# Auto-selection (inlined from IdleBehavior.next)
# ------------------------------------------------------------------
def _auto_select(self, context):
"""Scan auto-triggerable behaviors and return (name, kwargs) for the best one.
Returns (None, {}) to restart idle.
"""
if not context:
return None, {}
# Random meander (special case — checked before main selection)
# Skip when critically hungry so the pet focuses on feeding itself.
# Weight scaled down by sickness — very sick cats rarely wander.
_s = getattr(context, 'sickness', 0.0)
if _s >= 8.0:
_meander_p = 0.02
elif _s >= 5.0:
_meander_p = 0.06
elif _s >= 2.0:
_meander_p = 0.12
else:
_meander_p = 0.2
if context.fullness >= 5 and self.can_trigger_meandering(context) and random.random() <= _meander_p:
print("\033[32mRandomly meandering....\033[0m")
return 'meandering', {}
# Scene exit (special case — pet may decide to walk to a different location)
exit_result = self._auto_select_scene_exit(context)
if exit_result:
name, kwargs = exit_result
print("\033[32mScene exit -> %s\033[0m" % kwargs.get('pending_scene', '?'))
return name, kwargs
# High serenity makes the pet content to keep resting.
# Suppressed when critically hungry — a starving pet can't just lounge around.
if context.fullness >= 5 and context.serenity > 25 and random.random() < (context.serenity - 25) / 150:
print(f"\033[32mStaying idle (serenity: {context.serenity:.1f})\033[0m")
return None, {}
print("--------------------------------------------------------------------------------")
context.debug_print_stats()
candidates = []
for name in self._AUTO_SELECT_NAMES:
if self._sick_blocks(name, context):
continue
if getattr(self, 'can_trigger_' + name)(context):
candidates.append(name)
if not candidates:
return None, {}
priorities = {}
for name in candidates:
priorities[name] = max(0, getattr(self, 'priority_' + name)(context))
# Penalize recently completed behaviors to prevent loops.
# Most recent (index 0) gets +50, next +40, down to +10 at index 4.
# Sick pets get half the recency penalty for sleep/nap so they rest more.
_sick = getattr(context, 'sickness', 0.0) >= 2.0
for i, recent in enumerate(context.recent_behaviors):
if recent in priorities:
penalty = 50 - i * 10
if _sick and recent in ('sleeping', 'napping'):
penalty //= 2
priorities[recent] += penalty
for name in sorted(candidates, key=lambda n: priorities[n]):
recent_marker = ""
if name in context.recent_behaviors:
idx = context.recent_behaviors.index(name)
recent_marker = f" (+{50 - idx * 10} recency)"
print(f">> {name}: priority= {priorities[name]}{recent_marker}")
print("--------------------------------------------------------------------------------")
binned = {name: math.ceil(p / 10) * 10 for name, p in priorities.items()}
best_bin = min(binned.values())
top = [name for name, b in binned.items() if b == best_bin]
chosen = random.choice(top)
if len(top) > 1:
print(f">> Selected: {chosen} (from bin tied at {best_bin}: {top})")
kwargs = {}
if chosen == 'playing':
toys = context.inventory.get('toys', [])
solo = [t['variant'] for t in toys if t.get('variant') in self._SOLO_PLAY_VARIANTS]
kwargs = {'variant': random.choice(solo)}
return chosen, kwargs
# ------------------------------------------------------------------
# Scene exit selection
# ------------------------------------------------------------------
# Valid destinations from each main scene.
_SCENE_TRANSITIONS = {
'inside': ('bedroom', 'kitchen', 'outside'),
'bedroom': ('inside', 'kitchen'),
'kitchen': ('inside', 'bedroom'),
'outside': ('inside', 'treehouse'),
'treehouse': ('outside',),
}
def _auto_select_scene_exit(self, ctx):
"""Maybe walk to a new location. Returns ('go_to', kwargs) or None.
Base probability ~8%, boosted when needs align with a destination
(low fullness → kitchen, low comfort/energy → bedroom, bad weather → away
from outdoor destinations). Never totally deterministic.
"""
if getattr(ctx, 'on_vacation', False):
return None
current = getattr(ctx, 'last_main_scene', None)
options = self._SCENE_TRANSITIONS.get(current)
if not options:
return None
if ctx.energy < 15:
return None # Too exhausted to go anywhere
weather = ctx.environment.get('weather', 'Clear')
temp = ctx.environment.get('temperature', 20.0)
outdoor_bad = weather in ('Rain', 'Storm', 'Snow')
temp_extreme = temp < -1.0 or temp > 33.0 # retreat threshold
currently_outdoor = current in self._SICK_OUTDOOR
# Compute per-destination weights
weights = []
for dest in options:
w = 1.0
if dest == 'kitchen':
# Hungry pet more likely to head for food
w += max(0.0, 50.0 - ctx.fullness) * 0.04
if dest == 'bedroom':
# Tired or uncomfortable pet more likely to rest
w += max(0.0, 50.0 - ctx.comfort) * 0.02
w += max(0.0, 50.0 - ctx.energy) * 0.02
if dest in ('outside', 'treehouse') and outdoor_bad:
# Bad weather discourages outdoor trips
w *= 0.2
if dest in ('outside', 'treehouse') and temp_extreme:
# Extreme temperature discourages going/staying outside
w *= 0.2
if currently_outdoor and outdoor_bad and dest == 'inside':
# Pet wants to get indoors when caught outside in bad weather
w *= 3.0
if currently_outdoor and temp_extreme and dest == 'inside':
# Extreme temperature also drives the pet inside
w *= 2.5
weights.append(w)
# Boost effective probability when the strongest pull is high
max_w = max(weights)
effective_p = min(0.4, 0.08 + (max_w - 1.0) * 0.05)
if currently_outdoor and outdoor_bad:
# Pet urgently wants to leave — override probability by severity
floor_p = 0.70 if weather == 'Storm' else 0.45
effective_p = max(effective_p, floor_p)
if currently_outdoor and temp_extreme:
floor_p = 0.35
effective_p = max(effective_p, floor_p)
roll = random.random()
print("Scene exit p=%.3f roll=%.3f (%s) weights: %s" % (
effective_p, roll, current,
", ".join("%s=%.2f" % (d, w) for d, w in zip(options, weights))
))
if roll > effective_p:
return None
# Weighted random destination pick
total = sum(weights)
r = random.uniform(0.0, total)
chosen = options[-1]
for dest, w in zip(options, weights):
r -= w
if r <= 0.0:
chosen = dest
break
target_x = getattr(ctx, 'scene_x_min', 10)
return 'go_to', {'target_x': target_x, 'speed': 12, 'pending_scene': chosen}
# ------------------------------------------------------------------
# Sickness helpers
# ------------------------------------------------------------------
def _apply_sickness_accumulation(self, ctx):
"""Accumulate sickness after a behavior completes based on weather and fullness."""
delta = 0.0
if getattr(ctx, 'last_main_scene', None) in self._SICK_OUTDOOR:
weather = ctx.environment.get('weather', 'Clear')
if weather == 'Storm':
delta += 0.5
elif weather in ('Rain', 'Snow'):
delta += 0.25
if ctx.fullness < 10.0:
delta += 0.25
if ctx.cleanliness < 15.0:
delta += 0.25
if delta > 0.0:
ctx.sickness = min(10.0, ctx.sickness + delta)
print("[Sickness] +%.2f -> %.2f" % (delta, ctx.sickness))
def _sick_blocks(self, name, ctx):
"""Return True if sickness tier prevents this behavior from auto-selecting."""
s = getattr(ctx, 'sickness', 0.0)
if s < 2.0:
return False
if name in self._SICK_MILD_BLOCKED:
print("[Sickness] Blocking '%s' (sickness=%.2f)" % (name, s))
return True
if s >= 5.0 and name in self._SICK_CLEAR_BLOCKED:
print("[Sickness] Blocking '%s' (sickness=%.2f)" % (name, s))
return True
return False
# ------------------------------------------------------------------
# Wake greeting
# ------------------------------------------------------------------
def _pick_wake_greeting(self, ctx):
"""Return a behavior name for the wake-from-sleep greeting, or None to skip."""
if getattr(ctx, 'sickness', 0.0) >= 8.0:
return None
_NEED = 50
has_unmet_need = (
ctx.fullness < _NEED or ctx.affection < _NEED
or ctx.comfort < _NEED or ctx.fulfillment < _NEED
)
is_happy = ctx.energy > 40 and ctx.playfulness > 45
if has_unmet_need and random.random() < 0.75:
return 'vocalizing'
if is_happy and random.random() < 0.55:
return 'vocalizing'
return None
# ------------------------------------------------------------------
# can_trigger methods
# ------------------------------------------------------------------
def can_trigger_sleeping(self, ctx):
s = getattr(ctx, 'sickness', 0.0)
if s >= 8.0:
return True
if s >= 5.0:
threshold = 95
elif s >= 2.0:
threshold = 75
else:
h = ctx.environment.get('time_hours', 12)
in_bedroom = getattr(ctx, 'last_main_scene', None) == 'bedroom'
threshold = 70 if (h >= 21 or h < 6) else 40
if in_bedroom:
threshold += 20
trigger = ctx.energy < threshold
if not trigger:
print("Skipping sleeping. Energy: %6.4f" % ctx.energy)
return trigger
def can_trigger_napping(self, ctx):
s = getattr(ctx, 'sickness', 0.0)
if s >= 8.0:
return True
if s >= 5.0:
threshold = 97
elif s >= 2.0:
threshold = 85
else:
h = ctx.environment.get('time_hours', 12)
in_bedroom = getattr(ctx, 'last_main_scene', None) == 'bedroom'
threshold = 85 if (h >= 21 or h < 6) else 60
if in_bedroom:
threshold += 20
trigger = ctx.energy < threshold
if not trigger:
print("Skipping napping. Energy: %6.4f" % ctx.energy)
return trigger
def can_trigger_zoomies(self, ctx):
trigger = ctx.energy > 40 and ctx.playfulness > 40
if not trigger:
failures = []
if ctx.energy <= 40:
failures.append("Energy: %6.4f" % ctx.energy)
if ctx.playfulness <= 40:
failures.append("Playfulness: %6.4f" % ctx.playfulness)
print("Skipping zoomies. " + ", ".join(failures))
return trigger
def can_trigger_vocalizing(self, ctx):
_NEED = 60
happy = ctx.energy > 35 and ctx.playfulness > 40
needs_unmet = (ctx.fullness < _NEED or ctx.comfort < _NEED
or ctx.fulfillment < _NEED or ctx.affection < _NEED
or ctx.sociability < _NEED)
if getattr(ctx, 'last_main_scene', None) in self._SICK_OUTDOOR:
weather = ctx.environment.get('weather', 'Clear')
if weather in ('Rain', 'Storm', 'Snow'):
return True
trigger = happy or needs_unmet
if not trigger:
failures = []
if ctx.energy <= 35:
failures.append("Energy: %6.4f" % ctx.energy)
if ctx.playfulness <= 40:
failures.append("Playfulness: %6.4f" % ctx.playfulness)
print("Skipping vocalizing. " + ", ".join(failures))
return trigger
def can_trigger_hunting(self, ctx):
if ctx.fullness < 15 and ctx.energy > 20:
return True
outdoors = getattr(ctx, 'last_main_scene', None) in ('outside', 'treehouse')
threshold = 15 if outdoors else 20
trigger = ctx.energy > threshold and ctx.playfulness > threshold
if not trigger:
failures = []
if ctx.energy <= threshold:
failures.append("Energy: %6.4f" % ctx.energy)
if ctx.playfulness <= threshold:
failures.append("Playfulness: %6.4f" % ctx.playfulness)
print("Skipping hunting. " + ", ".join(failures))
return trigger
_SOLO_PLAY_VARIANTS = frozenset(('ball', 'string', 'feather', 'mouse'))
def can_trigger_playing(self, ctx):
if ctx.playfulness < 40:
return False
toys = ctx.inventory.get('toys', [])
has_solo_toy = any(t.get('variant') in self._SOLO_PLAY_VARIANTS for t in toys)
if not has_solo_toy:
print("Skipping playing. No solo toys in inventory.")
return has_solo_toy
def can_trigger_investigating(self, ctx):
trigger = ctx.curiosity >= 40
if not trigger:
print("Skipping investigating. Curiosity: %6.4f" % ctx.curiosity)
return trigger
def can_trigger_observing(self, ctx):
trigger = ctx.curiosity >= 30
if not trigger:
print("Skipping observing. Curiosity: %6.4f" % ctx.curiosity)
return trigger
def can_trigger_self_grooming(self, ctx):
trigger = ctx.cleanliness < 57 and ctx.energy > 30
if not trigger:
failures = []
if ctx.cleanliness >= 57:
failures.append("Cleanliness: %6.4f" % ctx.cleanliness)
if ctx.energy <= 30:
failures.append("Energy: %6.4f" % ctx.energy)
print("Skipping self grooming. " + ", ".join(failures))
return trigger
def can_trigger_stretching(self, ctx):
trigger = ctx.comfort < 55
if not trigger:
print("Skipping stretching. Comfort: %6.2f" % ctx.comfort)
return trigger
def can_trigger_pacing(self, ctx):
trigger = ctx.comfort < 70 and ctx.serenity < 65
if not trigger:
failures = []
if ctx.comfort >= 70:
failures.append("Comfort: %6.4f" % ctx.comfort)
if ctx.serenity >= 65:
failures.append("Serenity: %6.4f" % ctx.serenity)
print("Skipping pacing. " + ", ".join(failures))
return trigger
def can_trigger_sulking(self, ctx):
stats = (ctx.fullness, ctx.affection, ctx.fulfillment, ctx.comfort)
low_count = sum(1 for v in stats if v < 50)
trigger = (ctx.fulfillment < 50 or ctx.affection < 50
or low_count >= 2 or any(v < 25 for v in stats))
if not trigger:
print("Skipping sulking. Fulfillment: %6.4f, Affection: %6.4f, Fullness: %6.4f, Comfort: %6.4f" % (
ctx.fulfillment, ctx.affection, ctx.fullness, ctx.comfort))
return trigger
def can_trigger_mischief(self, ctx):
trigger = (ctx.mischievousness > 25 and ctx.maturity < 55
and ctx.playfulness > 50 and ctx.energy > 40)
if not trigger:
print("Skipping mischief. Mischievousness: %6.4f, Maturity: %6.4f" % (ctx.mischievousness, ctx.maturity))
return trigger
def can_trigger_hiding(self, ctx):
trigger = ctx.courage < 65 and (ctx.affection < 55 or ctx.energy < 55)
if not trigger:
print("Skipping hiding. Courage: %6.4f" % ctx.courage)
return trigger
def can_trigger_lounging(self, ctx):
trigger = ctx.focus > 30 and ctx.serenity > 30
if not trigger:
failures = []
if ctx.focus <= 30:
failures.append("Focus: %6.2f" % ctx.focus)
if ctx.serenity <= 30:
failures.append("Serenity: %6.2f" % ctx.serenity)
print("Skipping lounging. " + ", ".join(failures))
return trigger
def can_trigger_startled(self, ctx):
p = 0.45 * (1 - ctx.courage / 100)
trigger = random.random() < p
if not trigger:
print("Skipping startled. p=%.3f, Courage %6.4f" % (p, ctx.courage))
return trigger
def can_trigger_meandering(self, ctx):
trigger = ctx.energy > 20
if not trigger:
print("Skipping meandering. Energy: %6.4f" % ctx.energy)
return trigger
# ------------------------------------------------------------------
# Priority methods
# ------------------------------------------------------------------
def priority_sleeping(self, ctx):
base = random.uniform(ctx.energy * 0.25, max(ctx.energy * 0.25, ctx.energy * 2))
h = ctx.environment.get('time_hours', 12)
if h >= 19 or h < 6:
base *= 0.4
if getattr(ctx, 'last_main_scene', None) == 'bedroom':
base *= 0.55
return base
def priority_napping(self, ctx):
base = random.uniform(ctx.energy * 0.3, max(ctx.energy * 0.5, ctx.energy * 2.5))
h = ctx.environment.get('time_hours', 12)
if h >= 19 or h < 6:
base *= 0.5
if getattr(ctx, 'last_main_scene', None) == 'bedroom':
base *= 0.55
return base
def priority_zoomies(self, ctx):
base = random.uniform(100 - ctx.playfulness * 1.5, ctx.playfulness * 1.5)
if getattr(ctx, 'in_familiar_location', False):
base *= 0.85 # more likely to zoom in a safe, familiar place
return base
def priority_vocalizing(self, ctx):
if getattr(ctx, 'wants_to_go_home', False):
return random.uniform(2, 8) # very high priority — wins most selection rounds
if getattr(ctx, 'last_main_scene', None) in self._SICK_OUTDOOR:
weather = ctx.environment.get('weather', 'Clear')
temp = ctx.environment.get('temperature', 20.0)
weather_bad = weather in ('Rain', 'Storm', 'Snow')
temp_complaint = temp < 2.0 or temp > 30.0 # vocalize threshold
if (weather_bad or temp_complaint) and 'vocalizing' not in ctx.recent_behaviors:
urgency = 1.0
if weather == 'Storm':
urgency = 1.5
if temp < -1.0 or temp > 33.0: # retreat threshold → more urgent
urgency = max(urgency, 2.0)
return random.uniform(12, 22) / urgency
# Outdoors and not recently vocalized: cats are chatty outside —
# push priority low enough to win selection roughly every ~5 behaviors.
# Checked first so mild urgency can't bypass it.
espnow = getattr(ctx, 'espnow', None)
if espnow and espnow.active and 'vocalizing' not in ctx.recent_behaviors:
return random.uniform(5, 15)
# How urgently each need demands communication
_NEED = 40
hunger_deficit = max(0, _NEED - ctx.fullness) # hungry → vocalize before hunting
loneliness = max(0, _NEED - ctx.sociability) # lonely
affection_deficit = max(0, _NEED - ctx.affection) # wants affection
comfort_deficit = max(0, _NEED - ctx.comfort)
play_deficit = max(0, _NEED - ctx.playfulness) # wants to play
urgency = max(hunger_deficit, loneliness, affection_deficit,
comfort_deficit, play_deficit)
if urgency > 0:
# urgency=40 (stat=0) → priority 5; urgency=17 (fullness=23) → priority 14
return max(5, 65 - urgency * 3)
# Happy and energetic: chatty but not urgent
return random.uniform(25, max(25, (200 - ctx.energy - ctx.playfulness) * 0.5))
def priority_hunting(self, ctx):
hunger_pull = 100 - ctx.fullness # hungry → high pull (eat-motivated)
play_pull = ctx.playfulness # playful → high pull (fun/gift-motivated)
# Both pulls lower the ceiling (more competitive hunting); hunger additionally
# raises the floor so vocalizing can still win first when needs are unmet.
ceiling = max(25, 85 - play_pull * 0.5 - hunger_pull * 0.3)
floor = max(10, 25 + hunger_pull * 0.15 - play_pull * 0.1)
base = random.uniform(floor, max(floor + 5, ceiling))
if getattr(ctx, 'last_main_scene', None) in ('outside', 'treehouse'):
base *= 0.75
# When critically hungry the raised floor would let curiosity behaviors
# (investigate, observe ~10-50) keep slipping ahead of hunting. Cap the
# priority so hunting wins right after vocalizing has had its one turn.
if ctx.fullness < 5:
base = min(base, 15 + ctx.fullness * 0.5)
return base
def priority_playing(self, ctx):
base = random.uniform(100 - ctx.playfulness * 1.5, ctx.playfulness * 1.5)
if getattr(ctx, 'in_familiar_location', False):
base *= 0.85 # more playful in familiar surroundings
return base
def priority_investigating(self, ctx):
base = random.uniform(10, max(10, 100 - ctx.curiosity))
if not getattr(ctx, 'in_familiar_location', False):
base *= 0.8 # more curious/exploratory in unfamiliar places
return base
def priority_observing(self, ctx):
return random.uniform(10, max(10, 100 - ctx.curiosity))
def priority_self_grooming(self, ctx):
return random.uniform(ctx.cleanliness * 0.5, ctx.cleanliness * 1.5) + random.uniform(0, max(10, ctx.energy * 0.25))
def priority_stretching(self, ctx):
return random.uniform(ctx.comfort * 0.4, max(10, ctx.comfort))
def priority_pacing(self, ctx):
worst = min(ctx.comfort, ctx.serenity)
base = random.uniform(10, max(10, 100 - (100 - worst) * 0.8))
if not getattr(ctx, 'in_familiar_location', False):
base *= 0.8 # more restless in unfamiliar places
return base
def priority_sulking(self, ctx):
combined = ctx.fulfillment + ctx.affection + ctx.fullness + ctx.comfort
low_count = sum(1 for v in (ctx.fullness, ctx.affection, ctx.fulfillment, ctx.comfort) if v < 50)
ceiling = max(10, combined * 0.225 - low_count * 5)
base = random.uniform(10, max(10, ceiling))
if not getattr(ctx, 'in_familiar_location', False):
base *= 0.85
return base
def priority_mischief(self, ctx):
return random.uniform(20, max(20, (200 - ctx.mischievousness - ctx.playfulness) * 0.5))
def priority_hiding(self, ctx):
base = random.uniform(15, max(15, ctx.courage))
if getattr(ctx, 'in_familiar_location', False):
base *= 1.4 # unlikely to hide somewhere safe and familiar
else:
base *= 0.65 # anxious in unknown territory
return base
def priority_lounging(self, ctx):
base = 100 - random.uniform(ctx.serenity * 0.5, min(90, ctx.serenity * 1.5))
if getattr(ctx, 'in_familiar_location', False):
base *= 0.8 # more likely to just relax at home
return max(5, base)
def priority_startled(self, ctx):
base = random.uniform(20, max(20, ctx.courage * 1.2))
if not getattr(ctx, 'in_familiar_location', False):
base *= 0.75 # on-edge in unknown territory
return base