CharacterManager 0.0.1a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1669 @@
1
+ """Dataclasses describing system information snapshots.
2
+
3
+ All dataclasses subclass :class:`BaseDictDataclass` so they expose a
4
+ ``as_dict()`` helper (which raises if the subclass forgets the decorator)
5
+ and a defensive ``as_class()`` for cloning.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, is_dataclass, field
11
+ from typing import Any, Dict, Iterable, List, Optional, Literal, Tuple, Union
12
+ from decimal import Decimal, InvalidOperation
13
+
14
+ import sys
15
+
16
+ from CharacterManager import (
17
+ DEFAULT_STATUS_DURATION, MIN_STAT, MAX_STAT, STATUS_TAGS, DEFAULT_RACE_FOR_VALIDATION,
18
+ DEFAULT_INVENTORY_MAX_WEIGHT, DEFAULT_AGE, DEFAULT_SEX, DEFAULT_SEXUALITY,
19
+ DEFAULT_WEIGHT, DEFAULT_HEIGHT, DEFAULT_RACE, DEFAULT_JOB, DEFAULT_SOCIAL_CLASS,
20
+ DEFAULT_IS_PREGNANT, DEFAULT_PREGNACY_STATE, WOMEN_OPTIONS, EQUIPMENT_SLOTS,
21
+ MIN_LEVEL, XP, XP_TO_NEXT, HP, MAX_HP, MP, MAX_MP, STAMINA, MAX_STAMINA,
22
+ GOLD, MAX_LEVEL, LEVEL_XP_GROWTH, LEVEL_UP_HP_GAIN, LEVEL_UP_MP_GAIN,
23
+ DEFAULT_JOB_RPG, DEFAULT_SKILL_MANA_COST, DEFAULT_SKILL_STAMINA_COST,
24
+ DEFAULT_SKILL_COOLDOWN, DEFAULT_SKILL_POWER, RACE_CONSTRAINTS,
25
+ DEFAULT_ITEM_TYPE, DEFAULT_ITEM_RARITY, DEFAULT_ITEM_WEIGHT, DEFAULT_ITEM_VALUE,
26
+ ITEM_TYPE_SLOTS, SEX_NPC, RACE_NPC,
27
+ SexLiteral, SexualityLiteral, RaceLiteral, JobRPGLiteral,
28
+ JobLiteral, SocialClassLiteral, EquipmentSlotLiteral,
29
+ ItemTypeLiteral, ItemRarityLiteral, RelationshipBlock
30
+ )
31
+
32
+ from CharacterManager.relationships import is_compatible
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Mixin
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ class BaseDictDataclass:
41
+ """Mixin that turns the subclass into a self-aware dataclass.
42
+
43
+ - :meth:`as_dict` returns a deep copy as a plain ``dict``.
44
+ - :meth:`as_class` returns a clone constructed from ``__dict__``.
45
+ Both raise ``TypeError`` if the host class is not a ``@dataclass``.
46
+ """
47
+
48
+ def as_dict(self) -> Dict[str, Any]:
49
+ if not is_dataclass(self):
50
+ raise TypeError(
51
+ f"{self.__class__.__name__} must be decorated with @dataclass "
52
+ "to use as_dict()."
53
+ )
54
+ return asdict(self)
55
+
56
+ def as_class(self) -> "BaseDictDataclass":
57
+ if not is_dataclass(self):
58
+ raise TypeError(
59
+ f"{self.__class__.__name__} must be decorated with @dataclass "
60
+ "to use as_class()."
61
+ )
62
+ return self.__class__(**self.__dict__)
63
+
64
+
65
+ @dataclass
66
+ class StatusEffect(BaseDictDataclass):
67
+ """A temporary time-bound effect on an entity.
68
+
69
+ Parameters
70
+ ----------
71
+ name : str
72
+ Identifier and display name.
73
+ duration : int
74
+ Number of turns remaining. ``0`` is treated as expired the
75
+ next time ``tick()`` is called.
76
+ stat_modifiers : dict[str, int]
77
+ Flat additive offsets applied per stat key (``"attack"``,
78
+ ``"defense"``, ``"luck"`` ...). Negative values are debuffs.
79
+ damage_per_turn : int
80
+ If > 0, deals this much damage when ``tick`` runs. The
81
+ ``damage_over_time`` tag is set automatically.
82
+ heal_per_turn : int
83
+ If > 0, heals this much when ``tick`` runs. The
84
+ ``heal_over_time`` tag is set automatically. ``damage_per_turn``
85
+ and ``heal_per_turn`` are mutually exclusive.
86
+ tags : tuple[str, ...]
87
+ Categorical labels used by ``StatusBar`` to answer questions
88
+ like "is this entity stunned?".
89
+ description : str
90
+ Human-readable description for UI/debug.
91
+ """
92
+
93
+ name: str = "unnamed"
94
+ duration: int = DEFAULT_STATUS_DURATION
95
+ stat_modifiers: dict[str, int] = field(default_factory=dict)
96
+ damage_per_turn: int = 0
97
+ heal_per_turn: int = 0
98
+ tags: tuple[str, ...] = ()
99
+ description: str = ""
100
+
101
+ # ----------------------------------------------------------------
102
+ # Convenience constructors
103
+ # ----------------------------------------------------------------
104
+
105
+ @classmethod
106
+ def buff(
107
+ cls,
108
+ name: str,
109
+ *,
110
+ stat_modifiers: Union[dict[str, int], None] = None,
111
+ duration: int = DEFAULT_STATUS_DURATION,
112
+ description: str = "",
113
+ ) -> "StatusEffect":
114
+ return cls(
115
+ name=name,
116
+ duration=duration,
117
+ stat_modifiers=dict(stat_modifiers or {}),
118
+ tags=("buff",),
119
+ description=description,
120
+ )
121
+
122
+ @classmethod
123
+ def debuff(
124
+ cls,
125
+ name: str,
126
+ *,
127
+ stat_modifiers: Union[dict[str, int], None] = None,
128
+ duration: int = DEFAULT_STATUS_DURATION,
129
+ description: str = "",
130
+ ) -> "StatusEffect":
131
+ return cls(
132
+ name=name,
133
+ duration=duration,
134
+ stat_modifiers=dict(stat_modifiers or {}),
135
+ tags=("debuff",),
136
+ description=description,
137
+ )
138
+
139
+ @classmethod
140
+ def dot(
141
+ cls,
142
+ name: str,
143
+ damage: int,
144
+ *,
145
+ duration: int = DEFAULT_STATUS_DURATION,
146
+ description: str = "",
147
+ ) -> "StatusEffect":
148
+ return cls(
149
+ name=name,
150
+ duration=duration,
151
+ damage_per_turn=max(0, damage),
152
+ tags=("debuff", "damage_over_time"),
153
+ description=description,
154
+ )
155
+
156
+ @classmethod
157
+ def hot(
158
+ cls,
159
+ name: str,
160
+ heal: int,
161
+ *,
162
+ duration: int = DEFAULT_STATUS_DURATION,
163
+ description: str = "",
164
+ ) -> "StatusEffect":
165
+ return cls(
166
+ name=name,
167
+ duration=duration,
168
+ heal_per_turn=max(0, heal),
169
+ tags=("buff", "heal_over_time"),
170
+ description=description,
171
+ )
172
+
173
+ # ----------------------------------------------------------------
174
+ # Tick / lifecycle
175
+ # ----------------------------------------------------------------
176
+
177
+ def is_expired(self) -> bool:
178
+ return self.duration <= 0
179
+
180
+ def tick(self) -> None:
181
+ """Decrement remaining duration by 1 (never below zero)."""
182
+ self.duration = max(0, self.duration - 1)
183
+
184
+ def has_tag(self, tag: str) -> bool:
185
+ return tag in self.tags
186
+
187
+ def modifier_for(self, stat: str) -> int:
188
+ return self.stat_modifiers.get(stat, 0)
189
+
190
+ def validate(self) -> list[str]:
191
+ errors: list[str] = []
192
+ if not isinstance(self.name, str) or not self.name.strip():
193
+ errors.append(
194
+ f"name must be a non-empty string (got {self.name!r})."
195
+ )
196
+ if self.duration < 0:
197
+ errors.append(f"duration={self.duration} must be >= 0.")
198
+ if self.damage_per_turn < 0:
199
+ errors.append(
200
+ f"damage_per_turn={self.damage_per_turn} must be >= 0."
201
+ )
202
+ if self.heal_per_turn < 0:
203
+ errors.append(
204
+ f"heal_per_turn={self.heal_per_turn} must be >= 0."
205
+ )
206
+ if self.damage_per_turn > 0 and self.heal_per_turn > 0:
207
+ errors.append(
208
+ "damage_per_turn and heal_per_turn are mutually exclusive."
209
+ )
210
+ if not isinstance(self.stat_modifiers, dict):
211
+ errors.append(
212
+ f"stat_modifiers must be a dict "
213
+ f"(got {type(self.stat_modifiers).__name__})."
214
+ )
215
+ else:
216
+ for key, value in self.stat_modifiers.items():
217
+ if not isinstance(key, str):
218
+ errors.append(
219
+ f"stat_modifier key must be str (got {type(key).__name__})."
220
+ )
221
+ if (
222
+ not isinstance(value, int)
223
+ or value < MIN_STAT
224
+ or value > MAX_STAT
225
+ ):
226
+ errors.append(
227
+ f"stat_modifiers[{key!r}]={value!r} must be in "
228
+ f"[{MIN_STAT}, {MAX_STAT}]."
229
+ )
230
+ for tag in self.tags:
231
+ if tag not in STATUS_TAGS:
232
+ errors.append(
233
+ f"tag={tag!r} not in registered STATUS_TAGS={STATUS_TAGS}."
234
+ )
235
+ return errors
236
+
237
+
238
+ @dataclass
239
+ class StatusBar(BaseDictDataclass):
240
+ """Mutable container for an entity's active ``StatusEffect`` list.
241
+
242
+ Operations
243
+ ----------
244
+ - :py:meth:`add` pushes a new effect. Effects with the same ``name``
245
+ are considered the same effect — adding a buff with ``name="attack+10"``
246
+ a second time replaces the previous copy rather than stacking.
247
+ - :py:meth:`remove` drops by name (idempotent).
248
+ - :py:meth:`tick` decrements every effect's duration; returns the
249
+ list of names that just expired so the caller can show a toast
250
+ or reap other resources.
251
+ """
252
+
253
+ effects: list[StatusEffect] = field(default_factory=list)
254
+
255
+ def add(self, effect: StatusEffect) -> None:
256
+ # Same-name semantics: a new effect with the same name
257
+ # replaces the older one (RPG idiomatic — buffs don't stack).
258
+ for index, existing in enumerate(self.effects):
259
+ if existing.name == effect.name:
260
+ self.effects[index] = effect
261
+ return
262
+ self.effects.append(effect)
263
+
264
+ def add_many(self, effects: Iterable[StatusEffect]) -> int:
265
+ added = 0
266
+ for effect in effects:
267
+ self.add(effect)
268
+ added += 1
269
+ return added
270
+
271
+ def remove(self, name: str) -> bool:
272
+ """Return ``True`` when something was actually removed."""
273
+ before = len(self.effects)
274
+ self.effects = [e for e in self.effects if e.name != name]
275
+ return len(self.effects) < before
276
+
277
+ def find(self, name: str) -> Union[StatusEffect, None]:
278
+ for effect in self.effects:
279
+ if effect.name == name:
280
+ return effect
281
+ return None
282
+
283
+ def has_tag(self, tag: str) -> bool:
284
+ return any(effect.has_tag(tag) for effect in self.effects)
285
+
286
+ def is_stunned(self) -> bool:
287
+ return self.has_tag("stun")
288
+
289
+ def is_silenced(self) -> bool:
290
+ return self.has_tag("silence")
291
+
292
+ def is_invulnerable(self) -> bool:
293
+ return self.has_tag("invulnerable")
294
+
295
+ def total_modifier(self, stat: str) -> int:
296
+ """Sum modifiers across all effects for a given stat."""
297
+ return sum(effect.modifier_for(stat) for effect in self.effects)
298
+
299
+ def tick(self) -> list[str]:
300
+ """Advance every effect one turn; return expired names."""
301
+ expired: list[str] = []
302
+ for effect in self.effects:
303
+ effect.tick()
304
+ still_alive: list[StatusEffect] = []
305
+ for effect in self.effects:
306
+ if effect.is_expired():
307
+ expired.append(effect.name)
308
+ else:
309
+ still_alive.append(effect)
310
+ self.effects = still_alive
311
+ return expired
312
+
313
+ def clear(self) -> int:
314
+ """Drop all effects; return how many were dropped."""
315
+ count = len(self.effects)
316
+ self.effects = []
317
+ return count
318
+
319
+ def validate(self) -> list[str]:
320
+ errors: list[str] = []
321
+ if not isinstance(self.effects, list):
322
+ return [f"effects must be a list (got {type(self.effects).__name__})."]
323
+ for index, effect in enumerate(self.effects):
324
+ if not isinstance(effect, StatusEffect):
325
+ errors.append(
326
+ f"effects[{index}] must be StatusEffect "
327
+ f"(got {type(effect).__name__})."
328
+ )
329
+ continue
330
+ errors.extend(effect.validate())
331
+ return errors
332
+
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Validation helpers (private)
337
+ # ---------------------------------------------------------------------------
338
+
339
+
340
+ def _check_in_range(
341
+ name: str, value: float, lo: float, hi: float, errors: list[str]
342
+ ) -> None:
343
+ if value < lo or value > hi:
344
+ errors.append(f"{name}={value!r} must be in [{lo}, {hi}].")
345
+
346
+
347
+ def _effective_race(race: str) -> str:
348
+ """Return the race to use for validation, defaulting when em-dash."""
349
+ if not race or race == "\u2014":
350
+ return DEFAULT_RACE_FOR_VALIDATION
351
+ return race
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Character dataclass
356
+ # ---------------------------------------------------------------------------
357
+
358
+
359
+ @dataclass
360
+ class Character(BaseDictDataclass):
361
+ name: str = "Andrew"
362
+ age: int = DEFAULT_AGE
363
+ sex: str = DEFAULT_SEX
364
+ sexuality: str = DEFAULT_SEXUALITY
365
+ weight: float = DEFAULT_WEIGHT
366
+ height: Decimal = DEFAULT_HEIGHT
367
+ race: str = DEFAULT_RACE
368
+ job: str = DEFAULT_JOB
369
+ social_class: str = DEFAULT_SOCIAL_CLASS
370
+ pregnant: bool = DEFAULT_IS_PREGNANT
371
+ pregnancy: int = DEFAULT_PREGNACY_STATE
372
+
373
+ # ----------------------------------------------------------------
374
+ # Pickle / copy round-trip
375
+ # ----------------------------------------------------------------
376
+
377
+ def __getstate__(self) -> dict:
378
+ state = self.__dict__.copy()
379
+ if "height" in state and isinstance(state["height"], Decimal):
380
+ state["height"] = str(state["height"])
381
+ return state
382
+ def __setstate__(self, state: dict) -> None:
383
+ if "height" in state and isinstance(state["height"], str):
384
+ try:
385
+ state["height"] = Decimal(state["height"])
386
+ except InvalidOperation:
387
+ print(
388
+ "[Character.__setstate__] WARNING: height="
389
+ + repr(state["height"])
390
+ + " is not a valid Decimal; falling back to "
391
+ + "DEFAULT_HEIGHT.",
392
+ file=sys.stderr,
393
+ )
394
+ state["height"] = DEFAULT_HEIGHT
395
+ self.__dict__.update(state)
396
+
397
+ # ----------------------------------------------------------------
398
+ # Derived values
399
+ # ----------------------------------------------------------------
400
+
401
+ @property
402
+ def is_adult(self) -> bool:
403
+ constraint = RACE_CONSTRAINTS.get(
404
+ _effective_race(self.race),
405
+ RACE_CONSTRAINTS[DEFAULT_RACE_FOR_VALIDATION],
406
+ )
407
+ return self.age >= constraint.adult_age
408
+
409
+
410
+ @property
411
+ def can_get_pregnant(self) -> bool:
412
+ """``True`` only when biological sex allows pregnancy.
413
+
414
+ Gated by the ``sex`` field: only values in ``WOMEN_OPTIONS``
415
+ (``"woman"`` or ``"hermaphrodite"`` — simplification: not all
416
+ intersex people are biologically capable of getting pregnant,
417
+ so exposing this decision to the player may be desirable).
418
+ Returns ``False`` for ``"man"`` and for the em-dash
419
+ placeholder ``"—"`` (choice not yet made).
420
+
421
+ Invariant: callers that mutate ``pregnant`` must check this
422
+ property first — direct assignments to ``pregnant`` do not
423
+ go through the property.
424
+ """
425
+ return self.sex in WOMEN_OPTIONS
426
+
427
+ @property
428
+ def bmi(self) -> float:
429
+ """Body-mass index: kg / m² (height must be in meters)."""
430
+ if self.height <= 0:
431
+ return 0.0
432
+ h = float(self.height)
433
+ return self.weight / (h * h)
434
+
435
+ @property
436
+ def is_configured(self) -> bool:
437
+ """``True`` as soon as the player overwrites any default.
438
+
439
+ Includes ``age``: changing only the age also counts as
440
+ configured. Useful for creation screens that want to detect
441
+ incomplete profiles.
442
+ """
443
+ return (
444
+ self.age != DEFAULT_AGE
445
+ or self.weight != DEFAULT_WEIGHT
446
+ or self.height != DEFAULT_HEIGHT
447
+ or self.sex != DEFAULT_SEX
448
+ or self.sexuality != DEFAULT_SEXUALITY
449
+ or self.race != DEFAULT_RACE
450
+ or self.job != DEFAULT_JOB
451
+ or self.social_class != DEFAULT_SOCIAL_CLASS
452
+ or self.pregnant != DEFAULT_IS_PREGNANT
453
+ )
454
+
455
+ def reset_to_defaults(self) -> None:
456
+ """Restore to the neutral state, allowing re-creation."""
457
+ self.age = DEFAULT_AGE
458
+ self.weight = DEFAULT_WEIGHT
459
+ self.height = DEFAULT_HEIGHT
460
+ self.sex = DEFAULT_SEX
461
+ self.sexuality = DEFAULT_SEXUALITY
462
+ self.race = DEFAULT_RACE
463
+ self.job = DEFAULT_JOB
464
+ self.social_class = DEFAULT_SOCIAL_CLASS
465
+ self.pregnant = DEFAULT_IS_PREGNANT
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Combat stats
470
+ # ---------------------------------------------------------------------------
471
+
472
+
473
+ @dataclass
474
+ class Combat(BaseDictDataclass):
475
+ strength: int = 10 # physical strength
476
+ attack: int = 10 # physical attack
477
+ magic_attack: int = 10 # magic attack
478
+ defense: int = 10 # physical defense
479
+ magic_defense: int = 10 # magic defense
480
+ luck: int = 5 # luck (affects loot, crit chance, etc.)
481
+ accuracy: int = 95 # accuracy — % chance to hit (0..100)
482
+ crit_rate: float = 0.05 # critical hit chance (0.0..1.0)
483
+ crit_damage: float = 1.5 # critical hit damage multiplier
484
+
485
+ def __setstate__(self, state: Dict) -> None:
486
+ self.__dict__.update(state)
487
+
488
+ # ----------------------------------------------------------------
489
+ # Read-only derived values
490
+ # ----------------------------------------------------------------
491
+
492
+ @property
493
+ def will_always_crit(self) -> bool:
494
+ """True when crit rate is saturated — handy for bosses / cutscenes."""
495
+ return self.crit_rate >= 1.0
496
+
497
+ @property
498
+ def will_always_hit(self) -> bool:
499
+ """True when accuracy is saturated — useful for cutscene hits."""
500
+ return self.accuracy >= 100
501
+
502
+ @property
503
+ def wont_miss(self) -> bool:
504
+ """True when accuracy has dropped to 0 — fully blinded / dodged."""
505
+ return self.accuracy <= 0
506
+
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # RPG stats
510
+ # ---------------------------------------------------------------------------
511
+
512
+
513
+ @dataclass
514
+ class RPG(BaseDictDataclass):
515
+ level: int = MIN_LEVEL
516
+ xp: int = XP
517
+ xp_to_next: int = XP_TO_NEXT
518
+ hp: int = HP
519
+ max_hp: int = MAX_HP
520
+ mp: int = MP
521
+ max_mp: int = MAX_MP
522
+ stamina: int = STAMINA
523
+ max_stamina: int = MAX_STAMINA
524
+ gold: int = GOLD
525
+ job: str = DEFAULT_JOB_RPG
526
+
527
+ # ----------------------------------------------------------------
528
+ # Properties
529
+ # ----------------------------------------------------------------
530
+
531
+ @property
532
+ def is_alive(self) -> bool:
533
+ return self.hp > 0
534
+
535
+ @property
536
+ def hp_pct(self) -> float:
537
+ return (self.hp / self.max_hp) if self.max_hp > 0 else 0.0
538
+
539
+ @property
540
+ def mp_pct(self) -> float:
541
+ return (self.mp / self.max_mp) if self.max_mp > 0 else 0.0
542
+
543
+ @property
544
+ def stamina_pct(self) -> float:
545
+ return (self.stamina / self.max_stamina) if self.max_stamina > 0 else 0.0
546
+
547
+ @property
548
+ def xp_pct(self) -> float:
549
+ return (self.xp / self.xp_to_next) if self.xp_to_next > 0 else 0.0
550
+
551
+ @property
552
+ def xp_to_level(self) -> int:
553
+ """Remaining XP to next level (never negative)."""
554
+ return max(0, self.xp_to_next - self.xp)
555
+
556
+ # ----------------------------------------------------------------
557
+ # Level / XP
558
+ # ----------------------------------------------------------------
559
+
560
+ def gain_xp(self, amount: int) -> int:
561
+ """Add XP, possibly triggering multiple level-ups.
562
+
563
+ Returns the number of levels gained (0 if no level-up).
564
+ Caps at ``MAX_LEVEL`` — once reached, ``xp`` and ``xp_to_next``
565
+ are zeroed so the bar shows complete.
566
+ """
567
+ if amount <= 0:
568
+ return 0
569
+ self.xp += amount
570
+ levels = 0
571
+ while self.xp >= self.xp_to_next and self.level < MAX_LEVEL:
572
+ self.xp -= self.xp_to_next
573
+ self.level_up()
574
+ levels += 1
575
+ if self.level >= MAX_LEVEL:
576
+ self.xp = 0
577
+ self.xp_to_next = 0
578
+ return levels
579
+
580
+ def level_up(self) -> None:
581
+ """Increment level; bump XP curve and max pools; full heal."""
582
+ if self.level >= MAX_LEVEL:
583
+ return
584
+ self.level += 1
585
+ self.xp_to_next = max(1, int(self.xp_to_next * LEVEL_XP_GROWTH))
586
+ self.max_hp += LEVEL_UP_HP_GAIN
587
+ self.hp = self.max_hp
588
+ self.max_mp += LEVEL_UP_MP_GAIN
589
+ self.mp = self.max_mp
590
+
591
+ # ----------------------------------------------------------------
592
+ # HP / MP / Stamina
593
+ # ----------------------------------------------------------------
594
+
595
+ def take_damage(self, amount: int) -> int:
596
+ """Subtract HP clamped to 0. Returns HP actually lost."""
597
+ if amount <= 0:
598
+ return 0
599
+ dealt = min(self.hp, amount)
600
+ self.hp -= dealt
601
+ return dealt
602
+
603
+ def heal(self, amount: int) -> int:
604
+ """Restore HP clamped to max_hp. Returns HP actually restored."""
605
+ if amount <= 0:
606
+ return 0
607
+ room = self.max_hp - self.hp
608
+ restored = min(max(room, 0), amount)
609
+ self.hp += restored
610
+ return restored
611
+
612
+ def spend_mp(self, amount: int) -> bool:
613
+ """Try to spend MP. False if insufficient or non-positive."""
614
+ if amount <= 0 or self.mp < amount:
615
+ return False
616
+ self.mp -= amount
617
+ return True
618
+
619
+ def restore_mp(self, amount: int) -> int:
620
+ """Restore MP clamped to max_mp. Returns MP actually restored."""
621
+ if amount <= 0:
622
+ return 0
623
+ room = self.max_mp - self.mp
624
+ restored = min(max(room, 0), amount)
625
+ self.mp += restored
626
+ return restored
627
+
628
+ def spend_stamina(self, amount: int) -> bool:
629
+ if amount <= 0 or self.stamina < amount:
630
+ return False
631
+ self.stamina -= amount
632
+ return True
633
+
634
+ def restore_stamina(self, amount: int) -> int:
635
+ if amount <= 0:
636
+ return 0
637
+ room = self.max_stamina - self.stamina
638
+ restored = min(max(room, 0), amount)
639
+ self.stamina += restored
640
+ return restored
641
+
642
+ # ----------------------------------------------------------------
643
+ # Economy
644
+ # ----------------------------------------------------------------
645
+
646
+ def earn_gold(self, amount: int) -> int:
647
+ if amount <= 0:
648
+ return 0
649
+ self.gold += amount
650
+ return self.gold
651
+
652
+ def spend_gold(self, amount: int) -> bool:
653
+ if amount <= 0 or self.gold < amount:
654
+ return False
655
+ self.gold -= amount
656
+ return True
657
+
658
+
659
+ # ---------------------------------------------------------------------------
660
+ # Lock layer (visibility / placeholder)
661
+ # ---------------------------------------------------------------------------
662
+
663
+
664
+ @dataclass
665
+ class LockLayer(BaseDictDataclass):
666
+ """UI-level field visibility gate.
667
+
668
+ The set of locked field names is shared state — that is the point:
669
+ a UI screen calls ``is_locked("combat.attack")`` to decide whether
670
+ to render ``[Hidden]`` or the real value. Mutating helpers are
671
+ idempotent so careless code is fine.
672
+ """
673
+ _locked_fields: set[str] = field(default_factory=set)
674
+ # ----------------------------------------------------------------
675
+ # Lock layer (visibility / placeholder)
676
+ # ----------------------------------------------------------------
677
+ def is_locked(self, field_name: str) -> bool:
678
+ """Return ``True`` when ``field_name`` is in the locked set.
679
+
680
+ Cascades one level deep: if ``field_name`` is nested
681
+ (``"combat.attack"``) and the parent (``"combat"``) is in
682
+ the locked set, returns ``True`` too. This is the mechanism
683
+ that makes ``lock_combat_stats()`` gate every individual
684
+ combat field without enumerating them.
685
+
686
+ Parameters
687
+ ----------
688
+ field_name : str
689
+ Top-level (``"hp"``) or dot-prefixed (``"combat.attack"``).
690
+
691
+ Returns
692
+ -------
693
+ bool
694
+ ``True`` when the field should be hidden from the UI.
695
+ """
696
+ if not field_name:
697
+ return False
698
+ if field_name in self._locked_fields:
699
+ return True
700
+ parts = field_name.split(".", 1)
701
+ if len(parts) == 2 and parts[0] in self._locked_fields:
702
+ return True
703
+ return False
704
+
705
+ def lock(self, field_name: str) -> None:
706
+ """Add ``field_name`` to the locked set (idempotent).
707
+
708
+ Use for granular per-field gating. For category-level gating
709
+ (which is the common case), prefer the preset helpers
710
+ :py:meth:`lock_combat_stats`, :py:meth:`lock_character`,
711
+ :py:meth:`lock_inventory`, :py:meth:`lock_core_pools`.
712
+ """
713
+ if field_name:
714
+ self._locked_fields.add(field_name)
715
+
716
+ def unlock(self, field_name: str) -> None:
717
+ """Remove ``field_name`` from the locked set (idempotent).
718
+
719
+ ``KeyError`` is NOT raised when the field was not locked —
720
+ unlock is intended to be safe to call blindly.
721
+ """
722
+ self._locked_fields.discard(field_name)
723
+
724
+ @property
725
+ def locked_count(self) -> int:
726
+ return len(self._locked_fields)
727
+
728
+ # -- Category presets -------------------------------------------
729
+ # Each preset is sugar for ``self.lock(<single name>)`` on a
730
+ # parent field whose presence cascades every nested field via
731
+ # :py:meth:`is_locked`. Pair each lock_X with an unlock_X so
732
+ # readers don't need to remember the underlying field name.
733
+
734
+ def lock_many(self, *field_names: str) -> None:
735
+ for name in field_names:
736
+ self.lock(name)
737
+
738
+ def unlock_all(self) -> None:
739
+ """Clear every lock at once — master toggle."""
740
+ self._locked_fields.clear()
741
+
742
+ def get_for_display(
743
+ self,
744
+ field_name: str,
745
+ placeholder: object = "[Hidden]",
746
+ ) -> object:
747
+ """Return the real value or ``placeholder`` if locked.
748
+
749
+ Designed for UI screens. The double-purpose contract:
750
+
751
+ - When the field is **not** locked → returns the underlying
752
+ Python value (``100``, ``"human"``, ...) so the screen can
753
+ format it as usual (``"%d" %``, ``_("…")``, …).
754
+ - When the field **is** locked → returns ``placeholder``.
755
+ Default is the string ``"[Hidden]"``; screens may override
756
+ per-call (``"??? [Locked]"``, ``"—"``, an icon name, …).
757
+
758
+ Resolution rules
759
+ ----------------
760
+ - Top-level field (``"hp"``) → ``self.hp``.
761
+ - Dot-prefixed (``"combat.attack"``) → navigates
762
+ ``self.combat.attack``.
763
+ - Locked, but the attribute does not exist → ``placeholder``.
764
+
765
+ The function does NOT raise for unknown fields; that lets
766
+ screens call it speculatively on optional data without
767
+ try/except. Returning ``placeholder`` for both "locked" and
768
+ "missing" means the screen renders the same fallback in
769
+ both cases.
770
+
771
+ Parameters
772
+ ----------
773
+ field_name : str
774
+ The dotted field path to read.
775
+ placeholder : object, default ``"[Hidden]"``
776
+ Substitution value when locked or missing.
777
+
778
+ Returns
779
+ -------
780
+ object
781
+ The live value, or ``placeholder`` if locked/missing.
782
+ """
783
+ if self.is_locked(field_name):
784
+ return placeholder
785
+ parts = field_name.split(".", 1)
786
+ try:
787
+ if len(parts) == 2:
788
+ parent = getattr(self, parts[0])
789
+ return getattr(parent, parts[1])
790
+ return getattr(self, field_name)
791
+ except AttributeError:
792
+ return placeholder
793
+
794
+
795
+ # ---------------------------------------------------------------------------
796
+ # Inventory
797
+ # ---------------------------------------------------------------------------
798
+
799
+
800
+ @dataclass
801
+ class Item(BaseDictDataclass):
802
+ """A single item definition.
803
+
804
+ Items are cheap value objects: the same template can be referenced
805
+ by many inventory stacks. Carrying mutable state on an ``Item``
806
+ (per-instance flags) is discouraged — use ``effects`` for
807
+ equip-time bonuses.
808
+ """
809
+ name: str = "unnamed"
810
+ type: str = DEFAULT_ITEM_TYPE
811
+ rarity: str = DEFAULT_ITEM_RARITY
812
+ weight: float = DEFAULT_ITEM_WEIGHT
813
+ value: int = DEFAULT_ITEM_VALUE
814
+ description: str = ""
815
+ effects: list[StatusEffect] = field(default_factory=list)
816
+
817
+ # ----------------------------------------------------------------
818
+ # Slot affinity
819
+ # ----------------------------------------------------------------
820
+
821
+ def allowed_slots(self) -> tuple[str, ...]:
822
+ """Slots this item fits into, derived from ``type``."""
823
+ return ITEM_TYPE_SLOTS.get(self.type, ())
824
+
825
+ def can_equip_in(self, slot: str) -> bool:
826
+ return slot in self.allowed_slots()
827
+
828
+ @property
829
+ def is_equippable(self) -> bool:
830
+ return bool(self.allowed_slots())
831
+
832
+
833
+
834
+ # ---------------------------------------------------------------------------
835
+ # Inventory
836
+ # ---------------------------------------------------------------------------
837
+
838
+ @dataclass
839
+ class Inventory(BaseDictDataclass):
840
+ """A weighted bag of item stacks.
841
+
842
+ Slots in :attr:`slots` are ``(Item, count)`` tuples. We intentionally
843
+ use a plain ``list`` rather than a ``set`` or ``OrderedDict`` so
844
+ insertion order carries meaning — UI can show loot in pickup order.
845
+ """
846
+
847
+ slots: list[tuple[Item, int]] = field(default_factory=list)
848
+ max_weight: float = DEFAULT_INVENTORY_MAX_WEIGHT
849
+
850
+ # ----------------------------------------------------------------
851
+ # Lookup
852
+ # ----------------------------------------------------------------
853
+
854
+ def find(self, name: str) -> Optional[tuple[Item, int]]:
855
+ for item, count in self.slots:
856
+ if item.name == name:
857
+ return (item, count)
858
+ return None
859
+
860
+ def count_of(self, name: str) -> int:
861
+ match = self.find(name)
862
+ return match[1] if match else 0
863
+
864
+ def is_empty(self) -> bool:
865
+ return not self.slots
866
+
867
+ def is_full(self) -> bool:
868
+ return self.total_weight() >= self.max_weight
869
+
870
+ def weight_pct(self) -> float:
871
+ if self.max_weight <= 0:
872
+ return 0.0
873
+ return self.total_weight() / self.max_weight
874
+
875
+ def total_weight(self) -> float:
876
+ return sum(item.weight * count for item, count in self.slots)
877
+
878
+ def total_value(self) -> int:
879
+ return sum(item.value * count for item, count in self.slots)
880
+
881
+ # ----------------------------------------------------------------
882
+ # Mutations
883
+ # ----------------------------------------------------------------
884
+
885
+ def add(self, item: Item, count: int = 1) -> int:
886
+ """Add ``count`` of ``item``; returns how many slots were merged.
887
+
888
+ Returns ``count`` when the entire amount fit, ``kept`` (>= 0)
889
+ when only part fit due to the weight cap, and ``0`` if nothing
890
+ was added because of overflow.
891
+ """
892
+ if count <= 0:
893
+ return 0
894
+ # Same-name merge: stack onto existing entry.
895
+ for index, (existing, existing_count) in enumerate(self.slots):
896
+ if existing.name == item.name:
897
+ free = max(
898
+ 0,
899
+ int((self.max_weight - self.total_weight()) / item.weight),
900
+ )
901
+ free = min(free, count) if item.weight > 0 else count
902
+ if free <= 0:
903
+ return 0
904
+ self.slots[index] = (existing, existing_count + free)
905
+ return free
906
+ # New stack — same weight accounting.
907
+ free = (
908
+ count if item.weight <= 0 else min(
909
+ count,
910
+ int((self.max_weight - self.total_weight()) / item.weight),
911
+ )
912
+ )
913
+ if free <= 0:
914
+ return 0
915
+ self.slots.append((item, free))
916
+ return free
917
+
918
+ def remove(self, name: str, count: int = 1) -> int:
919
+ """Remove up to ``count`` items by name. Returns how many removed."""
920
+ if count <= 0:
921
+ return 0
922
+ for index, (item, current) in enumerate(self.slots):
923
+ if item.name != name:
924
+ continue
925
+ take = min(current, count)
926
+ remaining = current - take
927
+ if remaining <= 0:
928
+ del self.slots[index]
929
+ else:
930
+ self.slots[index] = (item, remaining)
931
+ return take
932
+ return 0
933
+
934
+ def clear(self) -> int:
935
+ """Drop every stack; return the count of items removed."""
936
+ removed = sum(count for _item, count in self.slots)
937
+ self.slots = []
938
+ return removed
939
+
940
+ def all_items(self) -> Iterable[Item]:
941
+ """Yield the unique items contained (one per stack, even if count>1)."""
942
+ for item, _count in self.slots:
943
+ yield item
944
+
945
+
946
+ # ---------------------------------------------------------------------------
947
+ # Equipment
948
+ # ---------------------------------------------------------------------------
949
+
950
+ @dataclass
951
+ class Equipment(BaseDictDataclass):
952
+ """Slot → Item map (``{slot_name: Item or None}``).
953
+
954
+ Each slot must be one of the names listed in ``EQUIPMENT_SLOTS``.
955
+ An item can only be equipped in a slot its ``type`` allows (see
956
+ :py:meth:`Item.allowed_slots`).
957
+ """
958
+
959
+ slots: dict[str, Optional[Item]] = field(default_factory=dict)
960
+
961
+ def __post_init__(self) -> None:
962
+ # Default all known slots to None so callers can check
963
+ # ``equipment.get("head")`` without surprise missing-keys.
964
+ for slot in EQUIPMENT_SLOTS:
965
+ self.slots.setdefault(slot, None)
966
+
967
+ # ----------------------------------------------------------------
968
+ # Getters
969
+ # ----------------------------------------------------------------
970
+
971
+ def get(self, slot: str) -> Optional[Item]:
972
+ return self.slots.get(slot)
973
+
974
+ def filled_slots(self) -> list[str]:
975
+ return [slot for slot, item in self.slots.items() if item is not None]
976
+
977
+ def empty_slots(self) -> list[str]:
978
+ return [slot for slot, item in self.slots.items() if item is None]
979
+
980
+ def is_equipped(self, slot: str) -> bool:
981
+ return self.slots.get(slot) is not None
982
+
983
+ def total_bonus(self, stat: str) -> int:
984
+ """Sum modifier-for-stat across every equipped item's effects."""
985
+ total = 0
986
+ for item in self.slots.values():
987
+ if item is None:
988
+ continue
989
+ for effect in item.effects:
990
+ total += effect.modifier_for(stat)
991
+ return total
992
+
993
+ def all_active_effects(self) -> list[StatusEffect]:
994
+ return [
995
+ effect
996
+ for item in self.slots.values()
997
+ if item is not None
998
+ for effect in item.effects
999
+ ]
1000
+
1001
+ # ----------------------------------------------------------------
1002
+ # Mutations
1003
+ # ----------------------------------------------------------------
1004
+
1005
+ def equip(self, item: Item, slot: Optional[str] = None) -> Optional[Item]:
1006
+ """Equip ``item`` into ``slot`` (auto-pick a valid slot if ``None``).
1007
+
1008
+ Returns the *previous* occupant of that slot (``None`` if empty).
1009
+
1010
+ Raises ``ValueError`` when ``slot`` is not in ``EQUIPMENT_SLOTS``
1011
+ or when ``item`` cannot be equipped into the requested slot.
1012
+ """
1013
+ if slot is None:
1014
+ allowed = item.allowed_slots()
1015
+ if not allowed:
1016
+ raise ValueError(
1017
+ f"item {item.name!r} (type={item.type!r}) cannot be equipped."
1018
+ )
1019
+ # Prefer first empty allowed slot to avoid stomping existing gear.
1020
+ for candidate in allowed:
1021
+ if self.slots.get(candidate) is None:
1022
+ slot = candidate
1023
+ break
1024
+ if slot is None:
1025
+ # Fall back to first slot of allowed.
1026
+ slot = allowed[0]
1027
+ if slot not in EQUIPMENT_SLOTS:
1028
+ raise ValueError(
1029
+ f"slot={slot!r} not in EQUIPMENT_SLOTS={EQUIPMENT_SLOTS}."
1030
+ )
1031
+ if not item.can_equip_in(slot):
1032
+ raise ValueError(
1033
+ f"item {item.name!r} cannot be equipped in slot={slot!r} "
1034
+ f"(allowed={item.allowed_slots()})."
1035
+ )
1036
+ previous = self.slots.get(slot)
1037
+ self.slots[slot] = item
1038
+ return previous
1039
+
1040
+ def unequip(self, slot: str) -> Optional[Item]:
1041
+ """Remove and return the item from ``slot`` (or ``None``)."""
1042
+ if slot not in EQUIPMENT_SLOTS:
1043
+ raise ValueError(
1044
+ f"slot={slot!r} not in EQUIPMENT_SLOTS={EQUIPMENT_SLOTS}."
1045
+ )
1046
+ existing = self.slots.get(slot)
1047
+ self.slots[slot] = None
1048
+ return existing
1049
+
1050
+ def unequip_all(self) -> list[Item]:
1051
+ """Drop everything; return the items that were equipped."""
1052
+ removed = [item for item in self.slots.values() if item is not None]
1053
+ for slot in EQUIPMENT_SLOTS:
1054
+ self.slots[slot] = None
1055
+ return removed
1056
+
1057
+ def auto_equip_from(self, inv: Inventory) -> int:
1058
+ """Best-effort equip: walk inventory, put first compatible item
1059
+ into the first allowed empty slot. Returns count equipped.
1060
+ """
1061
+ equipped = 0
1062
+ for slot in EQUIPMENT_SLOTS:
1063
+ if self.slots.get(slot) is not None:
1064
+ continue
1065
+ for index, (item, count) in enumerate(inv.slots):
1066
+ if count <= 0 or not item.can_equip_in(slot):
1067
+ continue
1068
+ self.slots[slot] = item
1069
+ # Consume one from the inventory.
1070
+ remaining = count - 1
1071
+ if remaining <= 0:
1072
+ del inv.slots[index]
1073
+ else:
1074
+ inv.slots[index] = (item, remaining)
1075
+ equipped += 1
1076
+ break
1077
+ return equipped
1078
+
1079
+
1080
+ # ----------------------------------------------------------------
1081
+ # Skill
1082
+ # ----------------------------------------------------------------
1083
+
1084
+
1085
+ @dataclass
1086
+ class Skill(BaseDictDataclass):
1087
+ """A spell or ability template.
1088
+
1089
+ Fields
1090
+ ------
1091
+ name : str
1092
+ Slot key in the ``SkillBook``.
1093
+ mana_cost : int
1094
+ stamina_cost : int
1095
+ cooldown : int
1096
+ Number of turns before the skill can be cast again.
1097
+ current_cooldown : int
1098
+ Mutable counter the ``SkillBook`` ticks down.
1099
+ power : int
1100
+ Generic magnitude — combat formulas can interpret it any way
1101
+ they want.
1102
+ description : str
1103
+ effects : list[StatusEffect]
1104
+ Templates stamped to the target's ``StatusBar`` on use.
1105
+ The list itself is not modified — each application creates
1106
+ a fresh ``StatusEffect`` instance via :py:meth:`instantiate_effects`.
1107
+ """
1108
+
1109
+ name: str = "unnamed"
1110
+ mana_cost: int = DEFAULT_SKILL_MANA_COST
1111
+ stamina_cost: int = DEFAULT_SKILL_STAMINA_COST
1112
+ cooldown: int = DEFAULT_SKILL_COOLDOWN
1113
+ current_cooldown: int = 0
1114
+ power: int = DEFAULT_SKILL_POWER
1115
+ description: str = ""
1116
+ effects: list[StatusEffect] = field(default_factory=list)
1117
+
1118
+ def is_ready(self) -> bool:
1119
+ return self.current_cooldown <= 0
1120
+
1121
+ def cost_feasible(self, rpg: RPG) -> bool:
1122
+ if self.mana_cost < 0 or self.stamina_cost < 0:
1123
+ return False
1124
+ if self.mana_cost > rpg.mp:
1125
+ return False
1126
+ if self.stamina_cost > rpg.stamina:
1127
+ return False
1128
+ return True
1129
+
1130
+ def tick_cooldown(self) -> None:
1131
+ """Advance the cooldown countdown by one turn."""
1132
+ self.current_cooldown = max(0, self.current_cooldown - 1)
1133
+
1134
+ def instantiate_effects(self) -> list[StatusEffect]:
1135
+ """Return fresh copies of ``self.effects`` ready to stamp on a target."""
1136
+ # Status effects are dataclasses; ``as_class()`` clones them.
1137
+ return [effect.as_class() for effect in self.effects] # pyright: ignore[reportReturnType]
1138
+
1139
+
1140
+ # ----------------------------------------------------------------
1141
+ # SkillBook
1142
+ # ----------------------------------------------------------------
1143
+
1144
+
1145
+ @dataclass
1146
+ class SkillBook(BaseDictDataclass):
1147
+ """Player's learned skills, keyed by name.
1148
+
1149
+ Operations
1150
+ ----------
1151
+ - :py:meth:`learn` and :py:meth:`forget` mutate the roster.
1152
+ - :py:meth:`use` casts a skill against a target.
1153
+ - :py:meth:`tick_all` advances cooldowns.
1154
+ """
1155
+
1156
+ skills: dict[str, Skill] = field(default_factory=dict)
1157
+
1158
+ def __contains__(self, name: str) -> bool:
1159
+ return name in self.skills
1160
+
1161
+ def __len__(self) -> int:
1162
+ return len(self.skills)
1163
+
1164
+ def names(self) -> list[str]:
1165
+ return list(self.skills.keys())
1166
+
1167
+ def get(self, name: str) -> Optional[Skill]:
1168
+ return self.skills.get(name)
1169
+
1170
+ def ready(self) -> list[Skill]:
1171
+ return [skill for skill in self.skills.values() if skill.is_ready()]
1172
+
1173
+ def on_cooldown(self) -> list[Skill]:
1174
+ return [skill for skill in self.skills.values() if not skill.is_ready()]
1175
+
1176
+ # ----------------------------------------------------------------
1177
+ # Mutate roster
1178
+ # ----------------------------------------------------------------
1179
+
1180
+ def learn(self, skill: Skill) -> bool:
1181
+ """Learn a skill; overwrites previous same-name entry.
1182
+
1183
+ Returns ``True`` if this was a new skill, ``False`` if it
1184
+ replaced one.
1185
+ """
1186
+ replacing = skill.name in self.skills
1187
+ self.skills[skill.name] = skill
1188
+ return not replacing
1189
+
1190
+ def forget(self, name: str) -> Optional[Skill]:
1191
+ return self.skills.pop(name, None)
1192
+
1193
+ def clear(self) -> int:
1194
+ count = len(self.skills)
1195
+ self.skills = {}
1196
+ return count
1197
+
1198
+ # ----------------------------------------------------------------
1199
+ # Cast
1200
+ # ----------------------------------------------------------------
1201
+
1202
+ def use(
1203
+ self,
1204
+ name: str,
1205
+ caster: RPG,
1206
+ target: StatusBar,
1207
+ ) -> list[StatusEffect]:
1208
+ """Cast ``name`` from ``caster``'s book onto ``target``.
1209
+
1210
+ Returns the list of :class:`StatusEffect` instances stamped
1211
+ onto the target's bar. On any failure path (``unknown``,
1212
+ ``on cooldown``, or ``insufficient resources``) returns an
1213
+ empty list and does NOT mutate ``caster``'s pools.
1214
+ """
1215
+ skill = self.skills.get(name)
1216
+ if skill is None:
1217
+ return []
1218
+ if not skill.is_ready():
1219
+ return []
1220
+ if not skill.cost_feasible(caster):
1221
+ return []
1222
+ # Drain resources first. ``spend_mp`` / ``spend_stamina`` are
1223
+ # already idempotent on failure paths.
1224
+ if skill.mana_cost > 0:
1225
+ caster.spend_mp(skill.mana_cost)
1226
+ if skill.stamina_cost > 0:
1227
+ caster.spend_stamina(skill.stamina_cost)
1228
+ # Stamp effects on the target.
1229
+ applied = skill.instantiate_effects()
1230
+ target.add_many(applied)
1231
+ # Trigger cooldown.
1232
+ skill.current_cooldown = skill.cooldown
1233
+ return applied
1234
+
1235
+ # ----------------------------------------------------------------
1236
+ # Cooldown management
1237
+ # ----------------------------------------------------------------
1238
+
1239
+ def tick_all(self) -> list[str]:
1240
+ """Advance every cooldown by one turn; return names that
1241
+ just became ready.
1242
+ """
1243
+ just_ready: list[str] = []
1244
+ for skill in self.skills.values():
1245
+ was_ready = skill.is_ready()
1246
+ skill.tick_cooldown()
1247
+ if not was_ready and skill.is_ready():
1248
+ just_ready.append(skill.name)
1249
+ return just_ready
1250
+
1251
+
1252
+ # ---------------------------------------------------------------------------
1253
+ # Character dataclass
1254
+ # ---------------------------------------------------------------------------
1255
+
1256
+
1257
+ @dataclass
1258
+ class Player(BaseDictDataclass):
1259
+ """The full entity.
1260
+
1261
+ Every sub-component is a plain ``field`` with a ``default_factory``
1262
+ so callers can build a Player with zero arguments and tweak the
1263
+ parts they care about.
1264
+ """
1265
+
1266
+ character: Character = field(default_factory=Character)
1267
+ combat: Combat = field(default_factory=Combat)
1268
+ rpg: RPG = field(default_factory=RPG)
1269
+ locked: LockLayer = field(default_factory=LockLayer)
1270
+ inventory: Inventory = field(default_factory=Inventory)
1271
+ equipment: Equipment = field(default_factory=Equipment)
1272
+ skillbook: SkillBook = field(default_factory=SkillBook)
1273
+ status: StatusBar = field(default_factory=StatusBar)
1274
+
1275
+ # ----------------------------------------------------------------
1276
+ # Convenience accessors
1277
+ # ----------------------------------------------------------------
1278
+
1279
+ def is_alive(self) -> bool:
1280
+ return self.rpg.is_alive
1281
+
1282
+ def tick_turn(self) -> Dict[str, Any]:
1283
+ """Advance one in-game turn: tick cooldowns, advance status bars.
1284
+
1285
+ Returns a small report dict so callers can surface messages:
1286
+ ``{"expired_status": [...], "ready_skills": [...]}``.
1287
+ """
1288
+ expired = self.status.tick()
1289
+ ready = self.skillbook.tick_all()
1290
+ return {"expired_status": expired, "ready_skills": ready}
1291
+
1292
+ def display(self, field_name: str, placeholder: object = "[Hidden]") -> object:
1293
+ """Return the live value or ``placeholder`` if locked.
1294
+
1295
+ UI sugar on top of :py:meth:`Player.locked`'s lock state. A
1296
+ UI screen calls this once per widget and renders the result
1297
+ directly, no lock-poking required.
1298
+
1299
+ Resolution rules
1300
+ ----------------
1301
+ - Top-level: ``"hp"`` → ``self.rpg.hp``.
1302
+ - One-deep prefix: ``"combat.attack"`` → ``self.combat.attack``.
1303
+ - More than one deep (e.g. ``"combat.attack.foo"``) resolves
1304
+ through ``self.combat.attack`` first; unknown subfields
1305
+ return ``placeholder`` (no raise).
1306
+ - **Locked** (cascading one level) → ``placeholder``.
1307
+ - **Missing** → ``placeholder``.
1308
+ """
1309
+ if self.locked.is_locked(field_name):
1310
+ return placeholder
1311
+ parts = field_name.split(".")
1312
+ try:
1313
+ cur: Any = self
1314
+ for part in parts:
1315
+ cur = getattr(cur, part)
1316
+ return cur
1317
+ except AttributeError:
1318
+ return placeholder
1319
+
1320
+ # ----------------------------------------------------------------
1321
+ # Setup
1322
+ # ----------------------------------------------------------------
1323
+
1324
+ def configure(
1325
+ self,
1326
+ name: str,
1327
+ age: int,
1328
+ sex: SexLiteral = DEFAULT_SEX,
1329
+ sexuality: SexualityLiteral = DEFAULT_SEXUALITY,
1330
+ weight: float = DEFAULT_WEIGHT,
1331
+ height: Decimal = DEFAULT_HEIGHT,
1332
+ race: RaceLiteral = DEFAULT_RACE,
1333
+ job: JobLiteral = DEFAULT_JOB,
1334
+ social_class: SocialClassLiteral = DEFAULT_SOCIAL_CLASS,
1335
+ pregnant: bool = DEFAULT_IS_PREGNANT,
1336
+ strength: int = 10,
1337
+ attack: int = 10,
1338
+ magic_attack: int = 10,
1339
+ defense: int = 10,
1340
+ magic_defense: int = 10,
1341
+ luck: int = 5,
1342
+ accuracy: int = 95,
1343
+ crit_rate: float = 0.05,
1344
+ crit_damage: float = 1.5,
1345
+ level: int = MIN_LEVEL,
1346
+ xp: int = XP,
1347
+ xp_to_next: int = XP_TO_NEXT,
1348
+ hp: int = HP,
1349
+ max_hp: int = MAX_HP,
1350
+ mp: int = MP,
1351
+ max_mp: int = MAX_MP,
1352
+ stamina: int = STAMINA,
1353
+ max_stamina: int = MAX_STAMINA,
1354
+ gold: int = GOLD,
1355
+ job_rpg: JobRPGLiteral = DEFAULT_JOB_RPG
1356
+ ):
1357
+ self.character.name = name
1358
+ self.character.age = age
1359
+ self.character.sex = sex
1360
+ self.character.sexuality = sexuality
1361
+ self.character.weight = weight
1362
+ self.character.height = height
1363
+ self.character.race = race
1364
+ self.character.job = job
1365
+ self.character.social_class = social_class
1366
+ self.character.pregnant = pregnant
1367
+ self.combat.strength = strength
1368
+ self.combat.attack = attack
1369
+ self.combat.magic_attack = magic_attack
1370
+ self.combat.defense = defense
1371
+ self.combat.magic_defense = magic_defense
1372
+ self.combat.luck = luck
1373
+ self.combat.accuracy = accuracy
1374
+ self.combat.crit_rate = crit_rate
1375
+ self.combat.crit_damage = crit_damage
1376
+ self.rpg.level =level
1377
+ self.rpg.xp = xp
1378
+ self.rpg.xp_to_next = xp_to_next
1379
+ self.rpg.hp = hp
1380
+ self.rpg.max_hp = max_hp
1381
+ self.rpg.mp = mp
1382
+ self.rpg.max_mp = max_mp
1383
+ self.rpg.stamina = stamina
1384
+ self.rpg.max_stamina = max_stamina
1385
+ self.rpg.gold = gold
1386
+ self.rpg.job = job_rpg
1387
+
1388
+
1389
+ @dataclass
1390
+ class NPC(BaseDictDataclass):
1391
+ """NPC / monster template. Instantiated per-encounter from script.rpy.
1392
+
1393
+ Distinct from PlayerStats because NPCs / monsters:
1394
+ - Don't have player-style character customization (no Character nested).
1395
+ - Reward XP and gold on defeat instead of gaining them.
1396
+ - Carry a ``friendly`` flag — True identifies shops / quest-givers /
1397
+ allies, False marks hostile monsters.
1398
+
1399
+ Reuses CombatStats via composition so damage formula calculations
1400
+ (``player.combat.attack`` vs ``npc.combat.defense``) can be shared
1401
+ with the player code without duplication.
1402
+
1403
+ ## Romantic relationship
1404
+
1405
+ Four new fields (``sex``, ``race``, ``age``,
1406
+ ``relationship_block``) + ``friendly`` feed
1407
+ ``can_romance_with(player)``, which returns ``True`` only
1408
+ when the NPC is:
1409
+
1410
+ 1. Passive (``friendly=True``) — hostiles do not romance.
1411
+ 2. Adult (``is_adult``) — children do not romance.
1412
+ 3. Has a defined sex (``sex != "\u2014"``) — monsters/goblins
1413
+ without biological sex are out of scope.
1414
+ 4. Is not in any categorical blocking block (married,
1415
+ religious, enemy, taken, child) — widowed is FREE.
1416
+ 5. Compatible with the player's sex+sexuality (table in
1417
+ ``relationships.COMPATIBILITY``).
1418
+
1419
+ NOTE: this dataclass is NOT wired into a `default` in
1420
+ variables.rpy — instantiate it from script.rpy when each npc /
1421
+ monster is encountered. Persisted via the active save if you store
1422
+ long-lived NPCs in a list/dict default of your choice.
1423
+
1424
+ Post-i18n note: ``sex``, ``race`` and ``relationship_block``
1425
+ use English stable identifiers (e.g. ``"man"``, ``"human"``,
1426
+ ``"free"``). The Portuguese / localized labels live in the
1427
+ ``_UI_*`` translation tables of
1428
+ ``game/renpy/character_creation.rpy``.
1429
+
1430
+ Mutations are all in-place.
1431
+ """
1432
+ character: Character = field(default_factory=Character)
1433
+ combat: Combat = field(default_factory=Combat)
1434
+ rpg: RPG = field(default_factory=RPG)
1435
+ locked: LockLayer = field(default_factory=LockLayer)
1436
+ equipment: Equipment = field(default_factory=Equipment)
1437
+ skillbook: SkillBook = field(default_factory=SkillBook)
1438
+ status: StatusBar = field(default_factory=StatusBar)
1439
+
1440
+ xp_reward: int = 10
1441
+ gold_reward: int = 0
1442
+ friendly: bool = True
1443
+ npc_id: str = ""
1444
+ relationship_block: RelationshipBlock = "free"
1445
+
1446
+ # ----------------------------------------------------------------
1447
+ # Convenience accessors
1448
+ # ----------------------------------------------------------------
1449
+
1450
+ @property
1451
+ def is_alive(self) -> bool:
1452
+ return self.rpg.hp > 0
1453
+
1454
+ @property
1455
+ def is_hostile(self) -> bool:
1456
+ return not self.friendly
1457
+
1458
+ @property
1459
+ def hp_pct(self) -> float:
1460
+ return (self.rpg.hp / self.rpg.max_hp) if self.rpg.max_hp > 0 else 0.0
1461
+
1462
+ @property
1463
+ def is_adult(self) -> bool:
1464
+ """``True`` when the NPC has age >= ``DEFAULT_AGE`` (18).
1465
+
1466
+ Uses ``DEFAULT_AGE`` imported from ``GameState.character`` to
1467
+ avoid drift if the legal age of majority changes in a
1468
+ patch (e.g.: 18 → 16). Aligned with ``Character.is_adult``.
1469
+ """
1470
+ constraint = RACE_CONSTRAINTS.get(
1471
+ _effective_race(self.character.race),
1472
+ RACE_CONSTRAINTS[DEFAULT_RACE_FOR_VALIDATION],
1473
+ )
1474
+ return self.character.age >= constraint.adult_age
1475
+
1476
+ @property
1477
+ def is_romanceable(self) -> bool:
1478
+ """``True`` if the NPC satisfies the cheap relational prerequisites (no player).
1479
+
1480
+ Cheap prerequisites that do NOT depend on the player:
1481
+ - ``friendly`` (hostiles never romance, even with free block)
1482
+ - ``is_adult`` (children never romance)
1483
+ - ``sex`` defined (not "\u2014") — entities without a
1484
+ defined sex (goblins, skeletons) are out of scope.
1485
+
1486
+ This property does NOT check sex/sexuality compatibility —
1487
+ that requires the player's ``Character`` and lives in
1488
+ ``can_romance_with``. Use this one for fast filtering
1489
+ in lists ("does this NPC have a category-compatible profile?"),
1490
+ before the final check that needs the player_character.
1491
+ """
1492
+ if not self.friendly:
1493
+ return False
1494
+ if not self.is_adult:
1495
+ return False
1496
+ if self.character.sex == "\u2014":
1497
+ return False
1498
+ return True
1499
+
1500
+ # ----------------------------------------------------------------
1501
+ # Compatibility with the player
1502
+ # ----------------------------------------------------------------
1503
+
1504
+ def can_romance_with(self, player) -> bool:
1505
+ """``True`` if this NPC can have a relationship with ``player``.
1506
+
1507
+ Composition of checks in decreasing barrier order:
1508
+
1509
+ 1. Cheap NPC prerequisites (``is_romanceable``):
1510
+ - NPC hostile → False
1511
+ - NPC child → False
1512
+ - NPC no sex → False
1513
+ 2. Player is configured:
1514
+ - ``Character.sex`` != "\u2014"
1515
+ - ``Character.sexuality`` != "\u2014"
1516
+ - ``Character.is_adult``
1517
+ 3. Pure rule (``is_compatible``, imported at the top):
1518
+ - categorized block does not block
1519
+ - NPC's sex is in the set returned by the table for
1520
+ the player's (sex, sexuality)
1521
+ """
1522
+ # --- 1) NPC prerequisites ---
1523
+ if not self.is_romanceable:
1524
+ return False
1525
+
1526
+ # --- 2) player prerequisites ---
1527
+ if player.character.sex == "\u2014":
1528
+ return False
1529
+ if player.character.sexuality == "\u2014":
1530
+ return False
1531
+ if not player.character.is_adult:
1532
+ return False
1533
+
1534
+ # --- 3) pure compatibility rule ---
1535
+ return is_compatible(
1536
+ player.character.sex,
1537
+ player.character.sexuality,
1538
+ self.character.sex,
1539
+ self.relationship_block
1540
+ )
1541
+
1542
+ # ----------------------------------------------------------------
1543
+ # HP
1544
+ # ----------------------------------------------------------------
1545
+
1546
+ def take_damage(self, amount: int) -> int:
1547
+ """Subtract HP clamped to 0. Returns HP actually lost."""
1548
+ if amount <= 0:
1549
+ return 0
1550
+ dealt = min(self.rpg.hp, amount)
1551
+ self.rpg.hp -= dealt
1552
+ return dealt
1553
+
1554
+ def heal(self, amount: int) -> int:
1555
+ """Restore HP clamped to max_hp. Returns HP actually restored."""
1556
+ if amount <= 0:
1557
+ return 0
1558
+ room = self.rpg.max_hp - self.rpg.hp
1559
+ restored = min(max(room, 0), amount)
1560
+ self.rpg.hp += restored
1561
+ return restored
1562
+
1563
+ # ----------------------------------------------------------------
1564
+ # Setup
1565
+ # ----------------------------------------------------------------
1566
+
1567
+ def configure(self, name: str, level: int, hp: int, friendly: bool = True,
1568
+ xp_reward: int = 10, gold_reward: int = 0,
1569
+ npc_id: str = "", sexuality: SexualityLiteral = "\u2014",
1570
+ sex: SEX_NPC = "\u2014", race: RACE_NPC = "human",
1571
+ age: int = DEFAULT_AGE,
1572
+ relationship_block: RelationshipBlock = "free",
1573
+ job: JobLiteral = DEFAULT_JOB, job_rpg: JobRPGLiteral = DEFAULT_JOB_RPG) -> None:
1574
+ """Bulk-set common fields in one call. Useful in encounter spawners.
1575
+
1576
+ New parameters (with safe defaults):
1577
+ - ``sex``: "\u2014" (no sex) is the default — change to
1578
+ ``"man"`` / ``"woman"`` / ``"hermaphrodite"`` on NPCs that
1579
+ can be romance targets.
1580
+ - ``race``: ``"human"`` is the default; pass ``"Goblin"``,
1581
+ ``"Bandido"``, etc. for monsters whose race isn't in the
1582
+ player's menu (the player-race check stays string-keyed
1583
+ so ad-hoc NPC races still work).
1584
+ - ``age``: ``DEFAULT_AGE`` (18) is the adult default;
1585
+ pass ``<18`` for children (and they are filtered in
1586
+ ``is_romanceable``).
1587
+ - ``relationship_block``: ``"free"`` by default; pass
1588
+ ``"married"`` / ``"religious"`` / ``"child"`` to block
1589
+ romances even if the compatibility table accepts.
1590
+ """
1591
+ self.character.name = name
1592
+ self.rpg.level = level
1593
+ self.rpg.hp = hp
1594
+ self.rpg.max_hp = hp
1595
+ self.friendly = friendly
1596
+ self.xp_reward = xp_reward
1597
+ self.gold_reward = gold_reward
1598
+ self.npc_id = npc_id
1599
+ self.character.sexuality = sexuality
1600
+ self.character.sex = sex
1601
+ self.character.race = race
1602
+ self.character.age = age
1603
+ self.relationship_block = relationship_block
1604
+ self.character.job = job
1605
+ self.rpg.job = job_rpg
1606
+
1607
+ # ----------------------------------------------------------------
1608
+ # Pickle / deepcopy defensive protocol
1609
+ # ----------------------------------------------------------------
1610
+ # Mirrors Character / GameDate / Location: every GameState
1611
+ # dataclass that touches persistent has a consistent pickle
1612
+ # contract. NPC has all primitives plus a ``combat:
1613
+ # CombatStats`` composition — CombatStats has its own
1614
+ # ``__setstate__`` for PT→EN field-name migration, so the
1615
+ # recursive CPython pickle protocol handles the composition.
1616
+ #
1617
+ # The ``__getstate__`` returns ``self.__dict__.copy()`` and the
1618
+ # ``combat`` field rides along as a live ``CombatStats`` instance
1619
+ # in the wire. If a future patch adds a fragile type directly
1620
+ # on NPC (Decimal, datetime, ...), convert it here and
1621
+ # reverse in ``__setstate__`` — mirroring Character's pattern.
1622
+
1623
+ def __getstate__(self) -> dict:
1624
+ """Pickle/deepcopy-safe state.
1625
+
1626
+ Pass-through ``self.__dict__.copy()`` today — matches the
1627
+ standard pickle protocol semantics: **shallow copy**. Primitive
1628
+ fields are independent (they're immutable). The nested
1629
+ ``combat: CombatStats`` field is the SAME live instance as
1630
+ ``self.combat`` (Python's ``dict.copy()`` doesn't recurse).
1631
+
1632
+ This is the intended pickle behaviour — pruning this contract
1633
+ would break the wire format. Concretely:
1634
+ - Mutating ``state["name"]`` does NOT affect the live instance.
1635
+ - Mutating ``state["combat"].strength`` DOES affect the live
1636
+ instance (same reference). That's the same as Python's
1637
+ default ``copy.copy`` semantics and matches pickle's
1638
+ default for dataclasses.
1639
+
1640
+ In practice this is a non-issue for Ren'Py: ``pickle.dumps``
1641
+ serialises the state to bytes (which breaks the reference),
1642
+ and ``pickle.loads`` reconstructs a fresh CombatStats. Tests
1643
+ that call ``__getstate__`` directly must respect the
1644
+ shallow-copy contract.
1645
+
1646
+ Override per-field conversion here when a fragile type is
1647
+ introduced directly on NPC (see Character for the
1648
+ Decimal-as-str pattern).
1649
+ """
1650
+ return self.__dict__.copy()
1651
+
1652
+ def __setstate__(self, state: dict) -> None:
1653
+ """Restore state from pickle/deepcopy.
1654
+
1655
+ No migration needed today. If you ever rename a field,
1656
+ add a ``_OLD_TO_NEW`` map here mirroring Character's pattern.
1657
+
1658
+ **Bypasses ``__init__``** — see ``GameDate.__setstate__``
1659
+ for the same caveat. ``__new__`` creates the instance,
1660
+ then state is written directly into ``__dict__``.
1661
+ """
1662
+ self.__dict__.update(state)
1663
+
1664
+
1665
+ __all__ = [
1666
+ "BaseDictDataclass", "Character",
1667
+ "Combat", "RPG", "LockLayer", "Item", "Inventory",
1668
+ "Skill", "SkillBook", "Player", "NPC"
1669
+ ]