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.
- CharacterManager/__init__.py +570 -0
- CharacterManager/data.py +1669 -0
- CharacterManager/relationships.py +39 -0
- charactermanager-0.0.1a0.dist-info/METADATA +158 -0
- charactermanager-0.0.1a0.dist-info/RECORD +7 -0
- charactermanager-0.0.1a0.dist-info/WHEEL +4 -0
- charactermanager-0.0.1a0.dist-info/licenses/LICENSE +21 -0
CharacterManager/data.py
ADDED
|
@@ -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
|
+
]
|