avrae-ls 0.6.0__py3-none-any.whl → 0.6.2__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.
- avrae_ls/__init__.py +3 -0
- avrae_ls/__main__.py +272 -0
- avrae_ls/alias_preview.py +371 -0
- avrae_ls/alias_tests.py +351 -0
- avrae_ls/api.py +2015 -0
- avrae_ls/argparser.py +430 -0
- avrae_ls/argument_parsing.py +67 -0
- avrae_ls/code_actions.py +282 -0
- avrae_ls/codes.py +3 -0
- avrae_ls/completions.py +1695 -0
- avrae_ls/config.py +480 -0
- avrae_ls/context.py +337 -0
- avrae_ls/cvars.py +115 -0
- avrae_ls/diagnostics.py +826 -0
- avrae_ls/dice.py +33 -0
- avrae_ls/parser.py +68 -0
- avrae_ls/runtime.py +750 -0
- avrae_ls/server.py +447 -0
- avrae_ls/signature_help.py +248 -0
- avrae_ls/symbols.py +274 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/METADATA +1 -1
- avrae_ls-0.6.2.dist-info/RECORD +34 -0
- draconic/__init__.py +4 -0
- draconic/exceptions.py +157 -0
- draconic/helpers.py +236 -0
- draconic/interpreter.py +1091 -0
- draconic/string.py +100 -0
- draconic/types.py +364 -0
- draconic/utils.py +78 -0
- draconic/versions.py +4 -0
- avrae_ls-0.6.0.dist-info/RECORD +0 -6
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/WHEEL +0 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/entry_points.txt +0 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/licenses/LICENSE +0 -0
avrae_ls/api.py
ADDED
|
@@ -0,0 +1,2015 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, ClassVar, Iterable, Mapping, MutableMapping, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
import d20
|
|
8
|
+
|
|
9
|
+
from .dice import RerollableStringifier
|
|
10
|
+
|
|
11
|
+
UNSET = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_int(value: Any, default: int = 0) -> int:
|
|
15
|
+
try:
|
|
16
|
+
return int(value)
|
|
17
|
+
except Exception:
|
|
18
|
+
return default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _DirMixin:
|
|
22
|
+
ATTRS: ClassVar[list[str]] = []
|
|
23
|
+
METHODS: ClassVar[list[str]] = []
|
|
24
|
+
|
|
25
|
+
def __dir__(self) -> list[str]:
|
|
26
|
+
data_keys = list(getattr(self, "_data", {}).keys())
|
|
27
|
+
return sorted(set(self.ATTRS + self.METHODS + data_keys))
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
# Ensure draconic's approx_len cache is initialized to a numeric value
|
|
31
|
+
# so nested objects don't trip TypeErrors when measured.
|
|
32
|
+
if getattr(self, "__approx_len__", None) is None:
|
|
33
|
+
self.__approx_len__ = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SimpleRollResult(_DirMixin):
|
|
37
|
+
ATTRS: ClassVar[list[str]] = ["dice", "total", "full", "result", "raw"]
|
|
38
|
+
METHODS: ClassVar[list[str]] = ["consolidated"]
|
|
39
|
+
|
|
40
|
+
def __init__(self, roll_result: d20.RollResult):
|
|
41
|
+
self._roll = roll_result
|
|
42
|
+
self.dice = d20.MarkdownStringifier().stringify(roll_result.expr.roll)
|
|
43
|
+
self.total = roll_result.total
|
|
44
|
+
self.full = str(roll_result)
|
|
45
|
+
self.result = roll_result
|
|
46
|
+
self.raw = roll_result.expr
|
|
47
|
+
self.__approx_len__ = 0
|
|
48
|
+
|
|
49
|
+
def consolidated(self) -> str:
|
|
50
|
+
d20.utils.simplify_expr(self._roll.expr, ambig_inherit="left")
|
|
51
|
+
return RerollableStringifier().stringify(self._roll.expr.roll)
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
return self.full
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# === Context API ===
|
|
58
|
+
@dataclass
|
|
59
|
+
class GuildAPI(_DirMixin):
|
|
60
|
+
_data: Mapping[str, Any] = field(default_factory=dict)
|
|
61
|
+
ATTRS: ClassVar[list[str]] = ["name", "id"]
|
|
62
|
+
METHODS: ClassVar[list[str]] = ["servsettings"]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def name(self) -> str:
|
|
66
|
+
return str(self._data.get("name", "Guild"))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def id(self) -> int | None:
|
|
70
|
+
raw = self._data.get("id")
|
|
71
|
+
return int(raw) if raw is not None else None
|
|
72
|
+
|
|
73
|
+
def servsettings(self) -> Mapping[str, Any] | None:
|
|
74
|
+
return self._data.get("servsettings")
|
|
75
|
+
|
|
76
|
+
def __getitem__(self, item: str) -> Any:
|
|
77
|
+
if isinstance(item, int):
|
|
78
|
+
raise TypeError("CustomCounter indices must be strings (e.g., 'value', 'max').")
|
|
79
|
+
return getattr(self, str(item))
|
|
80
|
+
|
|
81
|
+
def __iter__(self):
|
|
82
|
+
return iter(())
|
|
83
|
+
|
|
84
|
+
def __len__(self) -> int:
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class CategoryAPI(_DirMixin):
|
|
90
|
+
_data: Mapping[str, Any] = field(default_factory=dict)
|
|
91
|
+
ATTRS: ClassVar[list[str]] = ["name", "id"]
|
|
92
|
+
METHODS: ClassVar[list[str]] = []
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def name(self) -> str:
|
|
96
|
+
return str(self._data.get("name", "Category"))
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def id(self) -> int | None:
|
|
100
|
+
raw = self._data.get("id")
|
|
101
|
+
return int(raw) if raw is not None else None
|
|
102
|
+
|
|
103
|
+
def __getitem__(self, item: str) -> Any:
|
|
104
|
+
return getattr(self, str(item))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ChannelAPI(_DirMixin):
|
|
109
|
+
_data: Mapping[str, Any] = field(default_factory=dict)
|
|
110
|
+
ATTRS: ClassVar[list[str]] = ["name", "id", "topic", "category", "parent"]
|
|
111
|
+
METHODS: ClassVar[list[str]] = []
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def name(self) -> str:
|
|
115
|
+
return str(self._data.get("name", "channel"))
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def id(self) -> int | None:
|
|
119
|
+
raw = self._data.get("id")
|
|
120
|
+
return int(raw) if raw is not None else None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def topic(self) -> str | None:
|
|
124
|
+
val = self._data.get("topic")
|
|
125
|
+
return str(val) if val is not None else None
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def category(self) -> CategoryAPI | None:
|
|
129
|
+
cat = self._data.get("category")
|
|
130
|
+
return CategoryAPI(cat) if cat is not None else None
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def parent(self) -> ChannelAPI | None:
|
|
134
|
+
parent = self._data.get("parent")
|
|
135
|
+
return ChannelAPI(parent) if parent is not None else None
|
|
136
|
+
|
|
137
|
+
def __getitem__(self, item: str) -> Any:
|
|
138
|
+
return getattr(self, str(item))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class RoleAPI(_DirMixin):
|
|
143
|
+
_data: Mapping[str, Any] = field(default_factory=dict)
|
|
144
|
+
ATTRS: ClassVar[list[str]] = ["name", "id"]
|
|
145
|
+
METHODS: ClassVar[list[str]] = []
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def name(self) -> str:
|
|
149
|
+
return str(self._data.get("name", "Role"))
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def id(self) -> int | None:
|
|
153
|
+
raw = self._data.get("id")
|
|
154
|
+
return int(raw) if raw is not None else None
|
|
155
|
+
|
|
156
|
+
def __getitem__(self, item: str) -> Any:
|
|
157
|
+
return getattr(self, str(item))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class AuthorAPI(_DirMixin):
|
|
162
|
+
_data: Mapping[str, Any] = field(default_factory=dict)
|
|
163
|
+
ATTRS: ClassVar[list[str]] = ["name", "id", "discriminator", "display_name", "roles"]
|
|
164
|
+
METHODS: ClassVar[list[str]] = ["get_roles"]
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def name(self) -> str:
|
|
168
|
+
return str(self._data.get("name", "User"))
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def id(self) -> int | None:
|
|
172
|
+
raw = self._data.get("id")
|
|
173
|
+
return int(raw) if raw is not None else None
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def discriminator(self) -> str:
|
|
177
|
+
return str(self._data.get("discriminator", "0000"))
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def display_name(self) -> str:
|
|
181
|
+
return str(self._data.get("display_name", self.name))
|
|
182
|
+
|
|
183
|
+
def get_roles(self) -> list[RoleAPI]:
|
|
184
|
+
roles = self._data.get("roles") or []
|
|
185
|
+
return [RoleAPI(r) for r in roles]
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def roles(self) -> list[RoleAPI]:
|
|
189
|
+
return self.get_roles()
|
|
190
|
+
|
|
191
|
+
def __getitem__(self, item: str) -> Any:
|
|
192
|
+
return getattr(self, str(item))
|
|
193
|
+
|
|
194
|
+
def __str__(self) -> str:
|
|
195
|
+
return f"{self.name}#{self.discriminator}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class AliasContextAPI(_DirMixin):
|
|
200
|
+
_data: Mapping[str, Any] = field(default_factory=dict)
|
|
201
|
+
ATTRS: ClassVar[list[str]] = ["guild", "channel", "author", "prefix", "alias", "message_id"]
|
|
202
|
+
METHODS: ClassVar[list[str]] = []
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def guild(self) -> GuildAPI | None:
|
|
206
|
+
"""Guild info for the alias invocation (server context)."""
|
|
207
|
+
guild_data = self._data.get("guild")
|
|
208
|
+
return GuildAPI(guild_data) if guild_data is not None else None
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def channel(self) -> ChannelAPI | None:
|
|
212
|
+
"""Channel where the alias was invoked."""
|
|
213
|
+
channel_data = self._data.get("channel")
|
|
214
|
+
return ChannelAPI(channel_data) if channel_data is not None else None
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def author(self) -> AuthorAPI | None:
|
|
218
|
+
"""User who invoked the alias."""
|
|
219
|
+
author_data = self._data.get("author")
|
|
220
|
+
return AuthorAPI(author_data) if author_data is not None else None
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def prefix(self) -> str | None:
|
|
224
|
+
"""Command prefix that triggered the alias (e.g., `!`)."""
|
|
225
|
+
val = self._data.get("prefix")
|
|
226
|
+
return str(val) if val is not None else None
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def alias(self) -> str | None:
|
|
230
|
+
"""Alias name that was run."""
|
|
231
|
+
val = self._data.get("alias")
|
|
232
|
+
return str(val) if val is not None else None
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def message_id(self) -> int | None:
|
|
236
|
+
"""Discord message id for the invocation."""
|
|
237
|
+
raw = self._data.get("message_id")
|
|
238
|
+
return int(raw) if raw is not None else None
|
|
239
|
+
|
|
240
|
+
def __getattr__(self, item: str) -> Any:
|
|
241
|
+
return self._data.get(item)
|
|
242
|
+
|
|
243
|
+
def __getitem__(self, item: str) -> Any:
|
|
244
|
+
return getattr(self, str(item))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# === StatBlock primitives ===
|
|
248
|
+
_SKILL_CANONICAL = {
|
|
249
|
+
"acrobatics": "acrobatics",
|
|
250
|
+
"animalhandling": "animalHandling",
|
|
251
|
+
"arcana": "arcana",
|
|
252
|
+
"athletics": "athletics",
|
|
253
|
+
"deception": "deception",
|
|
254
|
+
"history": "history",
|
|
255
|
+
"initiative": "initiative",
|
|
256
|
+
"insight": "insight",
|
|
257
|
+
"intimidation": "intimidation",
|
|
258
|
+
"investigation": "investigation",
|
|
259
|
+
"medicine": "medicine",
|
|
260
|
+
"nature": "nature",
|
|
261
|
+
"perception": "perception",
|
|
262
|
+
"performance": "performance",
|
|
263
|
+
"persuasion": "persuasion",
|
|
264
|
+
"religion": "religion",
|
|
265
|
+
"sleightofhand": "sleightOfHand",
|
|
266
|
+
"sleight_of_hand": "sleightOfHand",
|
|
267
|
+
"stealth": "stealth",
|
|
268
|
+
"survival": "survival",
|
|
269
|
+
"strength": "strength",
|
|
270
|
+
"dexterity": "dexterity",
|
|
271
|
+
"constitution": "constitution",
|
|
272
|
+
"intelligence": "intelligence",
|
|
273
|
+
"wisdom": "wisdom",
|
|
274
|
+
"charisma": "charisma",
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_SKILL_ABILITIES = {
|
|
278
|
+
"acrobatics": "dexterity",
|
|
279
|
+
"animalHandling": "wisdom",
|
|
280
|
+
"arcana": "intelligence",
|
|
281
|
+
"athletics": "strength",
|
|
282
|
+
"deception": "charisma",
|
|
283
|
+
"history": "intelligence",
|
|
284
|
+
"initiative": "dexterity",
|
|
285
|
+
"insight": "wisdom",
|
|
286
|
+
"intimidation": "charisma",
|
|
287
|
+
"investigation": "intelligence",
|
|
288
|
+
"medicine": "wisdom",
|
|
289
|
+
"nature": "intelligence",
|
|
290
|
+
"perception": "wisdom",
|
|
291
|
+
"performance": "charisma",
|
|
292
|
+
"persuasion": "charisma",
|
|
293
|
+
"religion": "intelligence",
|
|
294
|
+
"sleightOfHand": "dexterity",
|
|
295
|
+
"stealth": "dexterity",
|
|
296
|
+
"survival": "wisdom",
|
|
297
|
+
"strength": "strength",
|
|
298
|
+
"dexterity": "dexterity",
|
|
299
|
+
"constitution": "constitution",
|
|
300
|
+
"intelligence": "intelligence",
|
|
301
|
+
"wisdom": "wisdom",
|
|
302
|
+
"charisma": "charisma",
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass
|
|
307
|
+
class AliasBaseStats(_DirMixin):
|
|
308
|
+
_data: Mapping[str, Any]
|
|
309
|
+
prof_bonus_override: int | None = None
|
|
310
|
+
ATTRS: ClassVar[list[str]] = [
|
|
311
|
+
"prof_bonus",
|
|
312
|
+
"strength",
|
|
313
|
+
"dexterity",
|
|
314
|
+
"constitution",
|
|
315
|
+
"intelligence",
|
|
316
|
+
"wisdom",
|
|
317
|
+
"charisma",
|
|
318
|
+
]
|
|
319
|
+
METHODS: ClassVar[list[str]] = ["get_mod", "get"]
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def prof_bonus(self) -> int:
|
|
323
|
+
if self.prof_bonus_override is not None:
|
|
324
|
+
return self.prof_bonus_override
|
|
325
|
+
return _safe_int(self._data.get("prof_bonus"), 2)
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def strength(self) -> int:
|
|
329
|
+
return _safe_int(self._data.get("strength"), 10)
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def dexterity(self) -> int:
|
|
333
|
+
return _safe_int(self._data.get("dexterity"), 10)
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def constitution(self) -> int:
|
|
337
|
+
return _safe_int(self._data.get("constitution"), 10)
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def intelligence(self) -> int:
|
|
341
|
+
return _safe_int(self._data.get("intelligence"), 10)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def wisdom(self) -> int:
|
|
345
|
+
return _safe_int(self._data.get("wisdom"), 10)
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def charisma(self) -> int:
|
|
349
|
+
return _safe_int(self._data.get("charisma"), 10)
|
|
350
|
+
|
|
351
|
+
def get_mod(self, stat: str) -> int:
|
|
352
|
+
stat_lower = str(stat).lower()
|
|
353
|
+
lookup = {
|
|
354
|
+
"str": self.strength,
|
|
355
|
+
"dex": self.dexterity,
|
|
356
|
+
"con": self.constitution,
|
|
357
|
+
"int": self.intelligence,
|
|
358
|
+
"wis": self.wisdom,
|
|
359
|
+
"cha": self.charisma,
|
|
360
|
+
"strength": self.strength,
|
|
361
|
+
"dexterity": self.dexterity,
|
|
362
|
+
"constitution": self.constitution,
|
|
363
|
+
"intelligence": self.intelligence,
|
|
364
|
+
"wisdom": self.wisdom,
|
|
365
|
+
"charisma": self.charisma,
|
|
366
|
+
}
|
|
367
|
+
score = lookup.get(stat_lower, 10)
|
|
368
|
+
return math.floor((score - 10) / 2)
|
|
369
|
+
|
|
370
|
+
def get(self, stat: str) -> int:
|
|
371
|
+
stat_lower = str(stat).lower()
|
|
372
|
+
lookup = {
|
|
373
|
+
"strength": self.strength,
|
|
374
|
+
"dexterity": self.dexterity,
|
|
375
|
+
"constitution": self.constitution,
|
|
376
|
+
"intelligence": self.intelligence,
|
|
377
|
+
"wisdom": self.wisdom,
|
|
378
|
+
"charisma": self.charisma,
|
|
379
|
+
}
|
|
380
|
+
return lookup.get(stat_lower, 10)
|
|
381
|
+
|
|
382
|
+
def __getitem__(self, item: str) -> Any:
|
|
383
|
+
return getattr(self, str(item))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@dataclass
|
|
387
|
+
class AliasLevels(_DirMixin):
|
|
388
|
+
_data: Mapping[str, Any]
|
|
389
|
+
ATTRS: ClassVar[list[str]] = ["total_level"]
|
|
390
|
+
METHODS: ClassVar[list[str]] = ["get"]
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def total_level(self) -> int | float:
|
|
394
|
+
total = 0
|
|
395
|
+
for _, value in self._data.items():
|
|
396
|
+
try:
|
|
397
|
+
total += value
|
|
398
|
+
except Exception:
|
|
399
|
+
try:
|
|
400
|
+
total += int(value)
|
|
401
|
+
except Exception:
|
|
402
|
+
continue
|
|
403
|
+
return total
|
|
404
|
+
|
|
405
|
+
def get(self, cls_name: str, default: int | float = 0) -> int | float:
|
|
406
|
+
val = self._data.get(str(cls_name))
|
|
407
|
+
if val is None:
|
|
408
|
+
return default
|
|
409
|
+
try:
|
|
410
|
+
return val
|
|
411
|
+
except Exception:
|
|
412
|
+
return default
|
|
413
|
+
|
|
414
|
+
def __iter__(self) -> Iterable[tuple[str, Any]]:
|
|
415
|
+
return iter(self._data.items())
|
|
416
|
+
|
|
417
|
+
def __getitem__(self, item: str) -> Any:
|
|
418
|
+
return self._data[item]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@dataclass
|
|
422
|
+
class AliasAttack(_DirMixin):
|
|
423
|
+
_data: Mapping[str, Any]
|
|
424
|
+
parent_statblock: Mapping[str, Any]
|
|
425
|
+
ATTRS: ClassVar[list[str]] = ["name", "verb", "proper", "activation_type", "raw"]
|
|
426
|
+
METHODS: ClassVar[list[str]] = []
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def name(self) -> str:
|
|
430
|
+
return str(self._data.get("name", "Attack"))
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def verb(self) -> str | None:
|
|
434
|
+
val = self._data.get("verb")
|
|
435
|
+
return str(val) if val is not None else None
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def proper(self) -> bool:
|
|
439
|
+
return bool(self._data.get("proper", False))
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def activation_type(self) -> int | None:
|
|
443
|
+
raw = self._data.get("activation_type")
|
|
444
|
+
return int(raw) if raw is not None else None
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def raw(self) -> Mapping[str, Any]:
|
|
448
|
+
return self._data.get("raw", self._data)
|
|
449
|
+
|
|
450
|
+
def __str__(self) -> str:
|
|
451
|
+
damage = self.raw.get("damage")
|
|
452
|
+
verb = f" {self.verb}" if self.verb else ""
|
|
453
|
+
if damage:
|
|
454
|
+
return f"{self.name}{verb}: {damage}"
|
|
455
|
+
return f"{self.name}{verb}".strip()
|
|
456
|
+
|
|
457
|
+
def __getitem__(self, item: str) -> Any:
|
|
458
|
+
return getattr(self, str(item))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@dataclass
|
|
462
|
+
class AliasAttackList(_DirMixin):
|
|
463
|
+
attacks: Sequence[Mapping[str, Any]]
|
|
464
|
+
parent_statblock: Mapping[str, Any]
|
|
465
|
+
ATTRS: ClassVar[list[str]] = []
|
|
466
|
+
METHODS: ClassVar[list[str]] = []
|
|
467
|
+
|
|
468
|
+
def __iter__(self) -> Iterable[AliasAttack]:
|
|
469
|
+
for atk in self.attacks:
|
|
470
|
+
yield AliasAttack(atk, self.parent_statblock)
|
|
471
|
+
|
|
472
|
+
def __getitem__(self, item: int | slice) -> Any:
|
|
473
|
+
if isinstance(item, slice):
|
|
474
|
+
return AliasAttackList(self.attacks[item], self.parent_statblock)
|
|
475
|
+
return AliasAttack(self.attacks[item], self.parent_statblock)
|
|
476
|
+
|
|
477
|
+
def __len__(self) -> int:
|
|
478
|
+
return len(self.attacks)
|
|
479
|
+
|
|
480
|
+
def __str__(self) -> str:
|
|
481
|
+
return "\n".join(str(AliasAttack(atk, self.parent_statblock)) for atk in self.attacks)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@dataclass
|
|
485
|
+
class AliasSkill(_DirMixin):
|
|
486
|
+
_data: Mapping[str, Any]
|
|
487
|
+
ATTRS: ClassVar[list[str]] = ["value", "prof", "bonus", "adv"]
|
|
488
|
+
METHODS: ClassVar[list[str]] = ["d20"]
|
|
489
|
+
|
|
490
|
+
@property
|
|
491
|
+
def value(self) -> int:
|
|
492
|
+
return _safe_int(self._data.get("value"), 0)
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def prof(self) -> float | int:
|
|
496
|
+
raw = self._data.get("prof")
|
|
497
|
+
try:
|
|
498
|
+
return float(raw)
|
|
499
|
+
except Exception:
|
|
500
|
+
return 0
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def bonus(self) -> int:
|
|
504
|
+
return _safe_int(self._data.get("bonus"), 0)
|
|
505
|
+
|
|
506
|
+
@property
|
|
507
|
+
def adv(self) -> bool | None:
|
|
508
|
+
val = self._data.get("adv")
|
|
509
|
+
if val is None:
|
|
510
|
+
return None
|
|
511
|
+
return bool(val)
|
|
512
|
+
|
|
513
|
+
def d20(self, base_adv=None, reroll=None, min_val=None, mod_override=None) -> str:
|
|
514
|
+
mod = mod_override if mod_override is not None else self.value
|
|
515
|
+
adv_prefix = "2d20kh1" if base_adv else "2d20kl1" if base_adv is False else "1d20"
|
|
516
|
+
suffix = f"+{mod}" if mod >= 0 else str(mod)
|
|
517
|
+
parts = [adv_prefix + suffix]
|
|
518
|
+
if reroll is not None:
|
|
519
|
+
parts.append(f"(reroll {reroll})")
|
|
520
|
+
if min_val is not None:
|
|
521
|
+
parts.append(f"(min {min_val})")
|
|
522
|
+
return " ".join(parts)
|
|
523
|
+
|
|
524
|
+
def __int__(self) -> int:
|
|
525
|
+
return self.value
|
|
526
|
+
|
|
527
|
+
def __repr__(self) -> str:
|
|
528
|
+
return f"<AliasSkill value={self.value} prof={self.prof} bonus={self.bonus} adv={self.adv}>"
|
|
529
|
+
|
|
530
|
+
def __gt__(self, other: Any) -> bool:
|
|
531
|
+
return self.value > other
|
|
532
|
+
|
|
533
|
+
def __ge__(self, other: Any) -> bool:
|
|
534
|
+
return self.value >= other
|
|
535
|
+
|
|
536
|
+
def __eq__(self, other: Any) -> bool:
|
|
537
|
+
return self.value == other
|
|
538
|
+
|
|
539
|
+
def __le__(self, other: Any) -> bool:
|
|
540
|
+
return self.value <= other
|
|
541
|
+
|
|
542
|
+
def __lt__(self, other: Any) -> bool:
|
|
543
|
+
return self.value < other
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@dataclass
|
|
547
|
+
class AliasSkills(_DirMixin):
|
|
548
|
+
_data: Mapping[str, Any]
|
|
549
|
+
prof_bonus: int
|
|
550
|
+
abilities: Mapping[str, int]
|
|
551
|
+
ATTRS: ClassVar[list[str]] = list(_SKILL_ABILITIES.keys())
|
|
552
|
+
METHODS: ClassVar[list[str]] = []
|
|
553
|
+
|
|
554
|
+
def __getattr__(self, item: str) -> AliasSkill:
|
|
555
|
+
return self._get_skill(item)
|
|
556
|
+
|
|
557
|
+
def __getitem__(self, item: str) -> AliasSkill:
|
|
558
|
+
return self._get_skill(str(item))
|
|
559
|
+
|
|
560
|
+
def _get_skill(self, name: str) -> AliasSkill:
|
|
561
|
+
normalized = _SKILL_CANONICAL.get(name.lower().replace(" ", "").replace("_", ""), name)
|
|
562
|
+
skill_data = self._data.get(normalized) or self._data.get(name) or {}
|
|
563
|
+
if not skill_data:
|
|
564
|
+
ability = _SKILL_ABILITIES.get(normalized)
|
|
565
|
+
ability_mod = 0
|
|
566
|
+
if ability:
|
|
567
|
+
ability_score = self.abilities.get(ability, 10)
|
|
568
|
+
ability_mod = math.floor((ability_score - 10) / 2)
|
|
569
|
+
skill_data = {"value": ability_mod, "prof": 0, "bonus": 0, "adv": None}
|
|
570
|
+
return AliasSkill(skill_data)
|
|
571
|
+
|
|
572
|
+
def __iter__(self) -> Iterable[tuple[str, AliasSkill]]:
|
|
573
|
+
for name in _SKILL_ABILITIES.keys():
|
|
574
|
+
yield name, self._get_skill(name)
|
|
575
|
+
|
|
576
|
+
def __str__(self) -> str:
|
|
577
|
+
return ", ".join(f"{name}: {skill.value}" for name, skill in self)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@dataclass
|
|
581
|
+
class AliasSaves(_DirMixin):
|
|
582
|
+
_data: Mapping[str, Any]
|
|
583
|
+
prof_bonus: int
|
|
584
|
+
abilities: Mapping[str, int]
|
|
585
|
+
ATTRS: ClassVar[list[str]] = []
|
|
586
|
+
METHODS: ClassVar[list[str]] = ["get"]
|
|
587
|
+
|
|
588
|
+
def get(self, base_stat: str) -> AliasSkill:
|
|
589
|
+
normalized = base_stat.lower()
|
|
590
|
+
raw = self._data.get(normalized) or self._data.get(base_stat) or {}
|
|
591
|
+
if isinstance(raw, (int, float)):
|
|
592
|
+
raw = {"value": raw}
|
|
593
|
+
if not raw:
|
|
594
|
+
ability_score = self.abilities.get(normalized, 10)
|
|
595
|
+
raw = {"value": math.floor((ability_score - 10) / 2)}
|
|
596
|
+
return AliasSkill(raw)
|
|
597
|
+
|
|
598
|
+
def __iter__(self) -> Iterable[tuple[str, AliasSkill]]:
|
|
599
|
+
for key in ("str", "dex", "con", "int", "wis", "cha"):
|
|
600
|
+
yield key, self.get(key)
|
|
601
|
+
|
|
602
|
+
def __str__(self) -> str:
|
|
603
|
+
return ", ".join(f"{name}: {skill.value}" for name, skill in self)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@dataclass
|
|
607
|
+
class ResistanceEntry:
|
|
608
|
+
dtype: str
|
|
609
|
+
unless: set[str] = field(default_factory=set)
|
|
610
|
+
only: set[str] = field(default_factory=set)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@dataclass
|
|
614
|
+
class AliasResistances(_DirMixin):
|
|
615
|
+
_data: Mapping[str, Any]
|
|
616
|
+
ATTRS: ClassVar[list[str]] = ["resist", "vuln", "immune", "neutral"]
|
|
617
|
+
METHODS: ClassVar[list[str]] = ["is_resistant", "is_immune", "is_vulnerable", "is_neutral"]
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def _entries(key: str, _data: Mapping[str, Any]) -> list[ResistanceEntry]:
|
|
621
|
+
entries = []
|
|
622
|
+
for entry in _data.get(key, []):
|
|
623
|
+
entries.append(
|
|
624
|
+
ResistanceEntry(
|
|
625
|
+
dtype=str(entry.get("dtype", "")),
|
|
626
|
+
unless=set(entry.get("unless", []) or []),
|
|
627
|
+
only=set(entry.get("only", []) or []),
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
return entries
|
|
631
|
+
|
|
632
|
+
@property
|
|
633
|
+
def resist(self) -> list[ResistanceEntry]:
|
|
634
|
+
return self._entries("resist", self._data)
|
|
635
|
+
|
|
636
|
+
@property
|
|
637
|
+
def vuln(self) -> list[ResistanceEntry]:
|
|
638
|
+
return self._entries("vuln", self._data)
|
|
639
|
+
|
|
640
|
+
@property
|
|
641
|
+
def immune(self) -> list[ResistanceEntry]:
|
|
642
|
+
return self._entries("immune", self._data)
|
|
643
|
+
|
|
644
|
+
@property
|
|
645
|
+
def neutral(self) -> list[ResistanceEntry]:
|
|
646
|
+
return self._entries("neutral", self._data)
|
|
647
|
+
|
|
648
|
+
def is_resistant(self, damage_type: str) -> bool:
|
|
649
|
+
token = str(damage_type).lower()
|
|
650
|
+
return self._matches(self.resist, token)
|
|
651
|
+
|
|
652
|
+
def is_immune(self, damage_type: str) -> bool:
|
|
653
|
+
token = str(damage_type).lower()
|
|
654
|
+
return self._matches(self.immune, token)
|
|
655
|
+
|
|
656
|
+
def is_vulnerable(self, damage_type: str) -> bool:
|
|
657
|
+
token = str(damage_type).lower()
|
|
658
|
+
return self._matches(self.vuln, token)
|
|
659
|
+
|
|
660
|
+
def is_neutral(self, damage_type: str) -> bool:
|
|
661
|
+
token = str(damage_type).lower()
|
|
662
|
+
return self._matches(self.neutral, token)
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
def _matches(entries: Iterable[ResistanceEntry], token: str) -> bool:
|
|
666
|
+
for entry in entries:
|
|
667
|
+
if entry.only and token not in entry.only:
|
|
668
|
+
continue
|
|
669
|
+
if entry.unless and token in entry.unless:
|
|
670
|
+
continue
|
|
671
|
+
if entry.dtype.lower() == token:
|
|
672
|
+
return True
|
|
673
|
+
return False
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@dataclass
|
|
677
|
+
class AliasSpellbookSpell(_DirMixin):
|
|
678
|
+
_data: Mapping[str, Any]
|
|
679
|
+
ATTRS: ClassVar[list[str]] = ["name", "dc", "sab", "mod", "prepared"]
|
|
680
|
+
METHODS: ClassVar[list[str]] = []
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def name(self) -> str:
|
|
684
|
+
return str(self._data.get("name", "Spell"))
|
|
685
|
+
|
|
686
|
+
@property
|
|
687
|
+
def dc(self) -> int | None:
|
|
688
|
+
raw = self._data.get("dc")
|
|
689
|
+
return int(raw) if raw is not None else None
|
|
690
|
+
|
|
691
|
+
@property
|
|
692
|
+
def sab(self) -> int | None:
|
|
693
|
+
raw = self._data.get("sab")
|
|
694
|
+
return int(raw) if raw is not None else None
|
|
695
|
+
|
|
696
|
+
@property
|
|
697
|
+
def mod(self) -> int | None:
|
|
698
|
+
raw = self._data.get("mod")
|
|
699
|
+
return int(raw) if raw is not None else None
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def prepared(self) -> bool:
|
|
703
|
+
return bool(self._data.get("prepared", True))
|
|
704
|
+
|
|
705
|
+
def __getitem__(self, item: str) -> Any:
|
|
706
|
+
return getattr(self, str(item))
|
|
707
|
+
|
|
708
|
+
def __str__(self) -> str:
|
|
709
|
+
return self.name
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@dataclass
|
|
713
|
+
class AliasSpellbook(_DirMixin):
|
|
714
|
+
_data: MutableMapping[str, Any]
|
|
715
|
+
_spells_cache: list[AliasSpellbookSpell] = field(default_factory=list, init=False)
|
|
716
|
+
ATTRS: ClassVar[list[str]] = [
|
|
717
|
+
"dc",
|
|
718
|
+
"sab",
|
|
719
|
+
"caster_level",
|
|
720
|
+
"spell_mod",
|
|
721
|
+
"spells",
|
|
722
|
+
"pact_slot_level",
|
|
723
|
+
"num_pact_slots",
|
|
724
|
+
"max_pact_slots",
|
|
725
|
+
]
|
|
726
|
+
METHODS: ClassVar[list[str]] = [
|
|
727
|
+
"find",
|
|
728
|
+
"slots_str",
|
|
729
|
+
"get_max_slots",
|
|
730
|
+
"get_slots",
|
|
731
|
+
"set_slots",
|
|
732
|
+
"use_slot",
|
|
733
|
+
"reset_slots",
|
|
734
|
+
"reset_pact_slots",
|
|
735
|
+
"remaining_casts_of",
|
|
736
|
+
"cast",
|
|
737
|
+
"can_cast",
|
|
738
|
+
]
|
|
739
|
+
|
|
740
|
+
@property
|
|
741
|
+
def dc(self) -> int:
|
|
742
|
+
return _safe_int(self._data.get("dc"), 10)
|
|
743
|
+
|
|
744
|
+
@property
|
|
745
|
+
def sab(self) -> int:
|
|
746
|
+
return _safe_int(self._data.get("sab"), 0)
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def caster_level(self) -> int:
|
|
750
|
+
return _safe_int(self._data.get("caster_level"), 0)
|
|
751
|
+
|
|
752
|
+
@property
|
|
753
|
+
def spell_mod(self) -> int:
|
|
754
|
+
return _safe_int(self._data.get("spell_mod"), 0)
|
|
755
|
+
|
|
756
|
+
@property
|
|
757
|
+
def spells(self) -> list[AliasSpellbookSpell]:
|
|
758
|
+
if not self._spells_cache:
|
|
759
|
+
spell_list = self._data.get("spells") or []
|
|
760
|
+
self._spells_cache = [AliasSpellbookSpell(s) for s in spell_list]
|
|
761
|
+
return self._spells_cache
|
|
762
|
+
|
|
763
|
+
@property
|
|
764
|
+
def pact_slot_level(self) -> int | None:
|
|
765
|
+
raw = self._data.get("pact_slot_level")
|
|
766
|
+
return int(raw) if raw is not None else None
|
|
767
|
+
|
|
768
|
+
@property
|
|
769
|
+
def num_pact_slots(self) -> int | None:
|
|
770
|
+
raw = self._data.get("num_pact_slots")
|
|
771
|
+
return int(raw) if raw is not None else None
|
|
772
|
+
|
|
773
|
+
@property
|
|
774
|
+
def max_pact_slots(self) -> int | None:
|
|
775
|
+
raw = self._data.get("max_pact_slots")
|
|
776
|
+
return int(raw) if raw is not None else None
|
|
777
|
+
|
|
778
|
+
def find(self, spell_name: str) -> list[AliasSpellbookSpell]:
|
|
779
|
+
needle = str(spell_name).lower()
|
|
780
|
+
return [spell for spell in self.spells if spell.name.lower() == needle]
|
|
781
|
+
|
|
782
|
+
def slots_str(self, level: int) -> str:
|
|
783
|
+
slots = self.get_slots(level)
|
|
784
|
+
max_slots = self.get_max_slots(level)
|
|
785
|
+
return f"{slots}/{max_slots}"
|
|
786
|
+
|
|
787
|
+
def get_max_slots(self, level: int) -> int:
|
|
788
|
+
slots = self._data.get("max_slots") or {}
|
|
789
|
+
return _safe_int(slots.get(int(level)), 0)
|
|
790
|
+
|
|
791
|
+
def get_slots(self, level: int) -> int:
|
|
792
|
+
slots = self._data.get("slots") or {}
|
|
793
|
+
if int(level) == 0:
|
|
794
|
+
return 1
|
|
795
|
+
return _safe_int(slots.get(int(level)), 0)
|
|
796
|
+
|
|
797
|
+
def set_slots(self, level: int, value: int, pact: bool = True) -> int:
|
|
798
|
+
slots = self._data.setdefault("slots", {})
|
|
799
|
+
slots[int(level)] = int(value)
|
|
800
|
+
return slots[int(level)]
|
|
801
|
+
|
|
802
|
+
def use_slot(self, level: int) -> int:
|
|
803
|
+
current = self.get_slots(level)
|
|
804
|
+
return self.set_slots(level, max(0, current - 1))
|
|
805
|
+
|
|
806
|
+
def reset_slots(self) -> None:
|
|
807
|
+
slots = self._data.get("slots") or {}
|
|
808
|
+
max_slots = self._data.get("max_slots") or {}
|
|
809
|
+
for level, maximum in max_slots.items():
|
|
810
|
+
slots[int(level)] = _safe_int(maximum)
|
|
811
|
+
self._data["slots"] = slots
|
|
812
|
+
|
|
813
|
+
def reset_pact_slots(self) -> None:
|
|
814
|
+
if self.max_pact_slots is None:
|
|
815
|
+
return
|
|
816
|
+
self._data["num_pact_slots"] = self.max_pact_slots
|
|
817
|
+
|
|
818
|
+
def remaining_casts_of(self, spell: str, level: int) -> str:
|
|
819
|
+
return self.slots_str(level)
|
|
820
|
+
|
|
821
|
+
def cast(self, spell: str, level: int) -> str:
|
|
822
|
+
self.use_slot(level)
|
|
823
|
+
return f"Casted {spell} at level {level}"
|
|
824
|
+
|
|
825
|
+
def can_cast(self, spell: str, level: int) -> bool:
|
|
826
|
+
return self.get_slots(level) > 0
|
|
827
|
+
|
|
828
|
+
def __contains__(self, item: object) -> bool:
|
|
829
|
+
if not isinstance(item, str):
|
|
830
|
+
return False
|
|
831
|
+
return any(spell.name.lower() == item.lower() for spell in self.spells)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@dataclass
|
|
835
|
+
class AliasCoinpurse(_DirMixin):
|
|
836
|
+
_data: MutableMapping[str, Any] = field(default_factory=dict)
|
|
837
|
+
ATTRS: ClassVar[list[str]] = ["pp", "gp", "ep", "sp", "cp", "total"]
|
|
838
|
+
METHODS: ClassVar[list[str]] = ["coin_str", "compact_str", "modify_coins", "set_coins", "autoconvert", "get_coins"]
|
|
839
|
+
|
|
840
|
+
def __getattr__(self, item: str) -> Any:
|
|
841
|
+
if item in {"pp", "gp", "ep", "sp", "cp"}:
|
|
842
|
+
return _safe_int(self._data.get(item), 0)
|
|
843
|
+
return self._data.get(item)
|
|
844
|
+
|
|
845
|
+
def __getitem__(self, item: str) -> Any:
|
|
846
|
+
return getattr(self, str(item))
|
|
847
|
+
|
|
848
|
+
@property
|
|
849
|
+
def total(self) -> float:
|
|
850
|
+
return self.pp * 10 + self.gp + self.ep * 0.5 + self.sp * 0.1 + self.cp * 0.01
|
|
851
|
+
|
|
852
|
+
def coin_str(self, cointype: str) -> str:
|
|
853
|
+
cointype = str(cointype)
|
|
854
|
+
value = getattr(self, cointype)
|
|
855
|
+
return f"{value} {cointype}"
|
|
856
|
+
|
|
857
|
+
def compact_str(self) -> str:
|
|
858
|
+
return f"{self.total:.2f} gp"
|
|
859
|
+
|
|
860
|
+
def modify_coins(
|
|
861
|
+
self,
|
|
862
|
+
pp: int = 0,
|
|
863
|
+
gp: int = 0,
|
|
864
|
+
ep: int = 0,
|
|
865
|
+
sp: int = 0,
|
|
866
|
+
cp: int = 0,
|
|
867
|
+
autoconvert: bool = True,
|
|
868
|
+
) -> dict[str, Any]:
|
|
869
|
+
self._data["pp"] = self.pp + int(pp)
|
|
870
|
+
self._data["gp"] = self.gp + int(gp)
|
|
871
|
+
self._data["ep"] = self.ep + int(ep)
|
|
872
|
+
self._data["sp"] = self.sp + int(sp)
|
|
873
|
+
self._data["cp"] = self.cp + int(cp)
|
|
874
|
+
return self.get_coins()
|
|
875
|
+
|
|
876
|
+
def set_coins(self, pp: int, gp: int, ep: int, sp: int, cp: int) -> None:
|
|
877
|
+
self._data["pp"] = int(pp)
|
|
878
|
+
self._data["gp"] = int(gp)
|
|
879
|
+
self._data["ep"] = int(ep)
|
|
880
|
+
self._data["sp"] = int(sp)
|
|
881
|
+
self._data["cp"] = int(cp)
|
|
882
|
+
|
|
883
|
+
def autoconvert(self) -> None:
|
|
884
|
+
total_cp = int(self.total * 100)
|
|
885
|
+
self._data["pp"], remainder = divmod(total_cp, 1000)
|
|
886
|
+
self._data["gp"], remainder = divmod(remainder, 100)
|
|
887
|
+
self._data["ep"], remainder = divmod(remainder, 50)
|
|
888
|
+
self._data["sp"], self._data["cp"] = divmod(remainder, 10)
|
|
889
|
+
|
|
890
|
+
def get_coins(self) -> dict[str, Any]:
|
|
891
|
+
return {"pp": self.pp, "gp": self.gp, "ep": self.ep, "sp": self.sp, "cp": self.cp, "total": self.total}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
@dataclass
|
|
895
|
+
class AliasDeathSaves(_DirMixin):
|
|
896
|
+
_data: MutableMapping[str, Any] = field(default_factory=dict)
|
|
897
|
+
ATTRS: ClassVar[list[str]] = ["successes", "fails"]
|
|
898
|
+
METHODS: ClassVar[list[str]] = ["succeed", "fail", "is_stable", "is_dead", "reset"]
|
|
899
|
+
|
|
900
|
+
@property
|
|
901
|
+
def successes(self) -> int:
|
|
902
|
+
return _safe_int(self._data.get("successes"), 0)
|
|
903
|
+
|
|
904
|
+
@property
|
|
905
|
+
def fails(self) -> int:
|
|
906
|
+
return _safe_int(self._data.get("fails"), 0)
|
|
907
|
+
|
|
908
|
+
def succeed(self, num: int = 1) -> None:
|
|
909
|
+
self._data["successes"] = self.successes + int(num)
|
|
910
|
+
|
|
911
|
+
def fail(self, num: int = 1) -> None:
|
|
912
|
+
self._data["fails"] = self.fails + int(num)
|
|
913
|
+
|
|
914
|
+
def is_stable(self) -> bool:
|
|
915
|
+
return self.successes >= 3 and self.fails < 3
|
|
916
|
+
|
|
917
|
+
def is_dead(self) -> bool:
|
|
918
|
+
return self.fails >= 3
|
|
919
|
+
|
|
920
|
+
def reset(self) -> None:
|
|
921
|
+
self._data["successes"] = 0
|
|
922
|
+
self._data["fails"] = 0
|
|
923
|
+
|
|
924
|
+
def __str__(self) -> str:
|
|
925
|
+
return f"{self.successes} successes / {self.fails} failures"
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@dataclass
|
|
929
|
+
class AliasAction(_DirMixin):
|
|
930
|
+
_data: Mapping[str, Any]
|
|
931
|
+
parent_statblock: Mapping[str, Any]
|
|
932
|
+
ATTRS: ClassVar[list[str]] = ["name", "activation_type", "activation_type_name", "description", "snippet"]
|
|
933
|
+
METHODS: ClassVar[list[str]] = []
|
|
934
|
+
|
|
935
|
+
@property
|
|
936
|
+
def name(self) -> str:
|
|
937
|
+
"""Action name."""
|
|
938
|
+
return str(self._data.get("name", "Action"))
|
|
939
|
+
|
|
940
|
+
@property
|
|
941
|
+
def activation_type(self) -> int | None:
|
|
942
|
+
"""Numeric activation type (matches Avrae constants)."""
|
|
943
|
+
raw = self._data.get("activation_type")
|
|
944
|
+
return int(raw) if raw is not None else None
|
|
945
|
+
|
|
946
|
+
@property
|
|
947
|
+
def activation_type_name(self) -> str | None:
|
|
948
|
+
"""Human-readable activation type (e.g., ACTION, BONUS_ACTION)."""
|
|
949
|
+
val = self._data.get("activation_type_name")
|
|
950
|
+
return str(val) if val is not None else None
|
|
951
|
+
|
|
952
|
+
@property
|
|
953
|
+
def description(self) -> str:
|
|
954
|
+
"""Long description of the action."""
|
|
955
|
+
return str(self._data.get("description", ""))
|
|
956
|
+
|
|
957
|
+
@property
|
|
958
|
+
def snippet(self) -> str:
|
|
959
|
+
"""Short snippet shown in the sheet for the action."""
|
|
960
|
+
return str(self._data.get("snippet", self.description))
|
|
961
|
+
|
|
962
|
+
def __str__(self) -> str:
|
|
963
|
+
return f"**{self.name}**: {self.description}"
|
|
964
|
+
|
|
965
|
+
def __getitem__(self, item: str) -> Any:
|
|
966
|
+
if isinstance(item, int) or (isinstance(item, str) and item.isdigit()):
|
|
967
|
+
raise TypeError("AliasAction attributes must be accessed by name (e.g., 'name', 'activation_type').")
|
|
968
|
+
return getattr(self, str(item))
|
|
969
|
+
|
|
970
|
+
def __iter__(self):
|
|
971
|
+
return iter(())
|
|
972
|
+
|
|
973
|
+
def __len__(self) -> int:
|
|
974
|
+
return 0
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@dataclass
|
|
978
|
+
class AliasCustomCounter(_DirMixin):
|
|
979
|
+
_data: MutableMapping[str, Any] = field(default_factory=dict)
|
|
980
|
+
ATTRS: ClassVar[list[str]] = [
|
|
981
|
+
"name",
|
|
982
|
+
"title",
|
|
983
|
+
"desc",
|
|
984
|
+
"value",
|
|
985
|
+
"max",
|
|
986
|
+
"min",
|
|
987
|
+
"reset_on",
|
|
988
|
+
"display_type",
|
|
989
|
+
"reset_to",
|
|
990
|
+
"reset_by",
|
|
991
|
+
]
|
|
992
|
+
METHODS: ClassVar[list[str]] = ["mod", "set", "reset", "full_str"]
|
|
993
|
+
|
|
994
|
+
@property
|
|
995
|
+
def name(self) -> str:
|
|
996
|
+
return str(self._data.get("name", "Counter"))
|
|
997
|
+
|
|
998
|
+
@property
|
|
999
|
+
def title(self) -> str | None:
|
|
1000
|
+
val = self._data.get("title")
|
|
1001
|
+
return str(val) if val is not None else None
|
|
1002
|
+
|
|
1003
|
+
@property
|
|
1004
|
+
def desc(self) -> str | None:
|
|
1005
|
+
val = self._data.get("desc")
|
|
1006
|
+
return str(val) if val is not None else None
|
|
1007
|
+
|
|
1008
|
+
@property
|
|
1009
|
+
def value(self) -> int:
|
|
1010
|
+
return _safe_int(self._data.get("value"), 0)
|
|
1011
|
+
|
|
1012
|
+
@property
|
|
1013
|
+
def max(self) -> int:
|
|
1014
|
+
return _safe_int(self._data.get("max"), 2**31 - 1)
|
|
1015
|
+
|
|
1016
|
+
@property
|
|
1017
|
+
def min(self) -> int:
|
|
1018
|
+
return _safe_int(self._data.get("min"), -(2**31))
|
|
1019
|
+
|
|
1020
|
+
@property
|
|
1021
|
+
def reset_on(self) -> str | None:
|
|
1022
|
+
val = self._data.get("reset_on")
|
|
1023
|
+
return str(val) if val is not None else None
|
|
1024
|
+
|
|
1025
|
+
@property
|
|
1026
|
+
def display_type(self) -> str | None:
|
|
1027
|
+
val = self._data.get("display_type")
|
|
1028
|
+
return str(val) if val is not None else None
|
|
1029
|
+
|
|
1030
|
+
@property
|
|
1031
|
+
def reset_to(self) -> int | None:
|
|
1032
|
+
raw = self._data.get("reset_to")
|
|
1033
|
+
return int(raw) if raw is not None else None
|
|
1034
|
+
|
|
1035
|
+
@property
|
|
1036
|
+
def reset_by(self) -> str | None:
|
|
1037
|
+
val = self._data.get("reset_by")
|
|
1038
|
+
return str(val) if val is not None else None
|
|
1039
|
+
|
|
1040
|
+
def mod(self, value: int, strict: bool = False) -> int:
|
|
1041
|
+
return self.set(self.value + int(value), strict)
|
|
1042
|
+
|
|
1043
|
+
def set(self, new_value: int, strict: bool = False) -> int:
|
|
1044
|
+
val = int(new_value)
|
|
1045
|
+
if strict:
|
|
1046
|
+
if val > self.max or val < self.min:
|
|
1047
|
+
raise ValueError("Counter out of bounds")
|
|
1048
|
+
val = max(self.min, min(val, self.max))
|
|
1049
|
+
self._data["value"] = val
|
|
1050
|
+
return val
|
|
1051
|
+
|
|
1052
|
+
def reset(self) -> dict[str, Any]:
|
|
1053
|
+
if self.reset_to is not None:
|
|
1054
|
+
target = self.reset_to
|
|
1055
|
+
elif self.reset_by is not None:
|
|
1056
|
+
target = self.value + _safe_int(self.reset_by, 0)
|
|
1057
|
+
else:
|
|
1058
|
+
target = self.max
|
|
1059
|
+
old_value = self.value
|
|
1060
|
+
new_value = self.set(target)
|
|
1061
|
+
return {"new_value": new_value, "old_value": old_value, "target_value": target, "delta": new_value - old_value}
|
|
1062
|
+
|
|
1063
|
+
def full_str(self, include_name: bool = False) -> str:
|
|
1064
|
+
prefix = f"**{self.name}**\n" if include_name else ""
|
|
1065
|
+
content = f"{self.value}/{self.max}"
|
|
1066
|
+
if self.display_type:
|
|
1067
|
+
content = self.display_type
|
|
1068
|
+
return prefix + content
|
|
1069
|
+
|
|
1070
|
+
def __str__(self) -> str:
|
|
1071
|
+
return f"{self.value}/{self.max}"
|
|
1072
|
+
|
|
1073
|
+
def __getitem__(self, item: str) -> Any:
|
|
1074
|
+
if isinstance(item, int) or (isinstance(item, str) and item.isdigit()):
|
|
1075
|
+
raise TypeError("CustomCounter indices must be strings (e.g., 'value', 'max').")
|
|
1076
|
+
return getattr(self, str(item))
|
|
1077
|
+
|
|
1078
|
+
def __iter__(self):
|
|
1079
|
+
return iter(())
|
|
1080
|
+
|
|
1081
|
+
def __len__(self) -> int:
|
|
1082
|
+
return 0
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
# === StatBlock + Character ===
|
|
1086
|
+
@dataclass
|
|
1087
|
+
class AliasStatBlock(_DirMixin):
|
|
1088
|
+
_data: MutableMapping[str, Any] = field(default_factory=dict)
|
|
1089
|
+
ATTRS: ClassVar[list[str]] = [
|
|
1090
|
+
"name",
|
|
1091
|
+
"stats",
|
|
1092
|
+
"levels",
|
|
1093
|
+
"attacks",
|
|
1094
|
+
"skills",
|
|
1095
|
+
"saves",
|
|
1096
|
+
"resistances",
|
|
1097
|
+
"ac",
|
|
1098
|
+
"max_hp",
|
|
1099
|
+
"hp",
|
|
1100
|
+
"temp_hp",
|
|
1101
|
+
"spellbook",
|
|
1102
|
+
"creature_type",
|
|
1103
|
+
]
|
|
1104
|
+
METHODS: ClassVar[list[str]] = ["set_hp", "modify_hp", "hp_str", "reset_hp", "set_temp_hp"]
|
|
1105
|
+
|
|
1106
|
+
def _prof_bonus(self) -> int:
|
|
1107
|
+
stats = self._data.get("stats") or {}
|
|
1108
|
+
if "prof_bonus" in stats:
|
|
1109
|
+
return _safe_int(stats.get("prof_bonus"), 2)
|
|
1110
|
+
levels = self._data.get("levels") or self._data.get("class_levels") or {}
|
|
1111
|
+
total_level = sum(_safe_int(val, 0) for val in levels.values()) or 1
|
|
1112
|
+
return max(2, 2 + (math.ceil(total_level / 4)))
|
|
1113
|
+
|
|
1114
|
+
@property
|
|
1115
|
+
def name(self) -> str:
|
|
1116
|
+
"""Character or statblock name."""
|
|
1117
|
+
return str(self._data.get("name", "Statblock"))
|
|
1118
|
+
|
|
1119
|
+
@property
|
|
1120
|
+
def stats(self) -> AliasBaseStats:
|
|
1121
|
+
"""Ability scores and proficiency bonus helper."""
|
|
1122
|
+
return AliasBaseStats(self._data.get("stats") or {}, prof_bonus_override=self._prof_bonus())
|
|
1123
|
+
|
|
1124
|
+
@property
|
|
1125
|
+
def levels(self) -> AliasLevels:
|
|
1126
|
+
"""Class levels keyed by class name."""
|
|
1127
|
+
return AliasLevels(self._data.get("levels") or self._data.get("class_levels") or {})
|
|
1128
|
+
|
|
1129
|
+
@property
|
|
1130
|
+
def attacks(self) -> AliasAttackList:
|
|
1131
|
+
"""Attacks available on the statblock."""
|
|
1132
|
+
return AliasAttackList(self._data.get("attacks") or [], self._data)
|
|
1133
|
+
|
|
1134
|
+
@property
|
|
1135
|
+
def skills(self) -> AliasSkills:
|
|
1136
|
+
"""Skill bonuses computed from abilities and prof bonus."""
|
|
1137
|
+
abilities = {
|
|
1138
|
+
"strength": self.stats.strength,
|
|
1139
|
+
"dexterity": self.stats.dexterity,
|
|
1140
|
+
"constitution": self.stats.constitution,
|
|
1141
|
+
"intelligence": self.stats.intelligence,
|
|
1142
|
+
"wisdom": self.stats.wisdom,
|
|
1143
|
+
"charisma": self.stats.charisma,
|
|
1144
|
+
}
|
|
1145
|
+
return AliasSkills(self._data.get("skills") or {}, self._prof_bonus(), abilities)
|
|
1146
|
+
|
|
1147
|
+
@property
|
|
1148
|
+
def saves(self) -> AliasSaves:
|
|
1149
|
+
"""Saving throw bonuses computed from abilities and prof bonus."""
|
|
1150
|
+
abilities = {
|
|
1151
|
+
"strength": self.stats.strength,
|
|
1152
|
+
"dexterity": self.stats.dexterity,
|
|
1153
|
+
"constitution": self.stats.constitution,
|
|
1154
|
+
"intelligence": self.stats.intelligence,
|
|
1155
|
+
"wisdom": self.stats.wisdom,
|
|
1156
|
+
"charisma": self.stats.charisma,
|
|
1157
|
+
"str": self.stats.strength,
|
|
1158
|
+
"dex": self.stats.dexterity,
|
|
1159
|
+
"con": self.stats.constitution,
|
|
1160
|
+
"int": self.stats.intelligence,
|
|
1161
|
+
"wis": self.stats.wisdom,
|
|
1162
|
+
"cha": self.stats.charisma,
|
|
1163
|
+
}
|
|
1164
|
+
return AliasSaves(self._data.get("saves") or {}, self._prof_bonus(), abilities)
|
|
1165
|
+
|
|
1166
|
+
@property
|
|
1167
|
+
def resistances(self) -> AliasResistances:
|
|
1168
|
+
"""Damage resistances, immunities, and vulnerabilities."""
|
|
1169
|
+
return AliasResistances(self._data.get("resistances") or {})
|
|
1170
|
+
|
|
1171
|
+
@property
|
|
1172
|
+
def ac(self) -> int | None:
|
|
1173
|
+
"""Armor class."""
|
|
1174
|
+
raw = self._data.get("ac")
|
|
1175
|
+
return int(raw) if raw is not None else None
|
|
1176
|
+
|
|
1177
|
+
@property
|
|
1178
|
+
def max_hp(self) -> int | None:
|
|
1179
|
+
"""Maximum hit points."""
|
|
1180
|
+
raw = self._data.get("max_hp")
|
|
1181
|
+
return int(raw) if raw is not None else None
|
|
1182
|
+
|
|
1183
|
+
@property
|
|
1184
|
+
def hp(self) -> int | None:
|
|
1185
|
+
"""Current hit points."""
|
|
1186
|
+
raw = self._data.get("hp")
|
|
1187
|
+
return int(raw) if raw is not None else None
|
|
1188
|
+
|
|
1189
|
+
@property
|
|
1190
|
+
def temp_hp(self) -> int:
|
|
1191
|
+
"""Temporary hit points."""
|
|
1192
|
+
return _safe_int(self._data.get("temp_hp"), 0)
|
|
1193
|
+
|
|
1194
|
+
@property
|
|
1195
|
+
def spellbook(self) -> AliasSpellbook:
|
|
1196
|
+
"""Known/prepared spells grouped by level."""
|
|
1197
|
+
return AliasSpellbook(self._data.get("spellbook") or {})
|
|
1198
|
+
|
|
1199
|
+
@property
|
|
1200
|
+
def creature_type(self) -> str | None:
|
|
1201
|
+
"""Creature type (e.g., humanoid, undead)."""
|
|
1202
|
+
val = self._data.get("creature_type")
|
|
1203
|
+
return str(val) if val is not None else None
|
|
1204
|
+
|
|
1205
|
+
def set_hp(self, new_hp: int) -> int:
|
|
1206
|
+
"""Set current hit points."""
|
|
1207
|
+
self._data["hp"] = int(new_hp)
|
|
1208
|
+
return self._data["hp"]
|
|
1209
|
+
|
|
1210
|
+
def modify_hp(self, amount: int, ignore_temp: bool = False, overflow: bool = True) -> int:
|
|
1211
|
+
"""Adjust hit points by `amount`, respecting overflow limits when requested."""
|
|
1212
|
+
hp = self.hp or 0
|
|
1213
|
+
new_hp = hp + int(amount)
|
|
1214
|
+
if not overflow and self.max_hp is not None:
|
|
1215
|
+
new_hp = max(0, min(new_hp, self.max_hp))
|
|
1216
|
+
self._data["hp"] = new_hp
|
|
1217
|
+
return new_hp
|
|
1218
|
+
|
|
1219
|
+
def hp_str(self) -> str:
|
|
1220
|
+
"""String summary of HP and temp HP."""
|
|
1221
|
+
return f"{self.hp}/{self.max_hp} (+{self.temp_hp} temp)"
|
|
1222
|
+
|
|
1223
|
+
def reset_hp(self) -> int:
|
|
1224
|
+
"""Restore to max HP and clear temp HP."""
|
|
1225
|
+
if self.max_hp is not None:
|
|
1226
|
+
self._data["hp"] = self.max_hp
|
|
1227
|
+
self._data["temp_hp"] = 0
|
|
1228
|
+
return self.hp or 0
|
|
1229
|
+
|
|
1230
|
+
def set_temp_hp(self, new_temp: int) -> int:
|
|
1231
|
+
"""Set temporary hit points."""
|
|
1232
|
+
self._data["temp_hp"] = int(new_temp)
|
|
1233
|
+
return self.temp_hp
|
|
1234
|
+
|
|
1235
|
+
def __getattr__(self, item: str) -> Any:
|
|
1236
|
+
return self._data.get(item)
|
|
1237
|
+
|
|
1238
|
+
def __getitem__(self, item: str) -> Any:
|
|
1239
|
+
if isinstance(item, int) or (isinstance(item, str) and item.isdigit()):
|
|
1240
|
+
raise TypeError("AliasStatBlock attributes must be accessed by name (e.g., 'name', 'stats').")
|
|
1241
|
+
return getattr(self, str(item))
|
|
1242
|
+
|
|
1243
|
+
def __iter__(self):
|
|
1244
|
+
return iter(())
|
|
1245
|
+
|
|
1246
|
+
def __len__(self) -> int:
|
|
1247
|
+
return 0
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
@dataclass
|
|
1251
|
+
class CharacterAPI(AliasStatBlock):
|
|
1252
|
+
ATTRS: ClassVar[list[str]] = AliasStatBlock.ATTRS + [
|
|
1253
|
+
"actions",
|
|
1254
|
+
"coinpurse",
|
|
1255
|
+
"csettings",
|
|
1256
|
+
"race",
|
|
1257
|
+
"background",
|
|
1258
|
+
"owner",
|
|
1259
|
+
"upstream",
|
|
1260
|
+
"sheet_type",
|
|
1261
|
+
"cvars",
|
|
1262
|
+
"consumables",
|
|
1263
|
+
"death_saves",
|
|
1264
|
+
"description",
|
|
1265
|
+
"image",
|
|
1266
|
+
]
|
|
1267
|
+
METHODS: ClassVar[list[str]] = AliasStatBlock.METHODS + [
|
|
1268
|
+
"cc",
|
|
1269
|
+
"get_cc",
|
|
1270
|
+
"get_cc_max",
|
|
1271
|
+
"get_cc_min",
|
|
1272
|
+
"set_cc",
|
|
1273
|
+
"mod_cc",
|
|
1274
|
+
"delete_cc",
|
|
1275
|
+
"create_cc_nx",
|
|
1276
|
+
"create_cc",
|
|
1277
|
+
"edit_cc",
|
|
1278
|
+
"cc_exists",
|
|
1279
|
+
"cc_str",
|
|
1280
|
+
"get_cvar",
|
|
1281
|
+
"set_cvar",
|
|
1282
|
+
"set_cvar_nx",
|
|
1283
|
+
"delete_cvar",
|
|
1284
|
+
]
|
|
1285
|
+
|
|
1286
|
+
def _consumable_map(self) -> MutableMapping[str, MutableMapping[str, Any]]:
|
|
1287
|
+
consumables = self._data.setdefault("consumables", {})
|
|
1288
|
+
if isinstance(consumables, list):
|
|
1289
|
+
# normalize list payloads to map
|
|
1290
|
+
mapped: dict[str, MutableMapping[str, Any]] = {}
|
|
1291
|
+
for item in consumables:
|
|
1292
|
+
name = str(item.get("name", f"cc_{len(mapped)}"))
|
|
1293
|
+
mapped[name] = dict(item)
|
|
1294
|
+
self._data["consumables"] = mapped
|
|
1295
|
+
consumables = mapped
|
|
1296
|
+
return consumables # type: ignore[return-value]
|
|
1297
|
+
|
|
1298
|
+
@property
|
|
1299
|
+
def actions(self) -> list[AliasAction]:
|
|
1300
|
+
"""Actions on the character sheet (mapped from Beyond/custom actions)."""
|
|
1301
|
+
acts = self._data.get("actions") or []
|
|
1302
|
+
return [AliasAction(a, self._data) for a in acts]
|
|
1303
|
+
|
|
1304
|
+
@property
|
|
1305
|
+
def coinpurse(self) -> AliasCoinpurse:
|
|
1306
|
+
"""Coin totals by denomination."""
|
|
1307
|
+
return AliasCoinpurse(self._data.get("coinpurse") or {})
|
|
1308
|
+
|
|
1309
|
+
@property
|
|
1310
|
+
def csettings(self) -> Mapping[str, Any]:
|
|
1311
|
+
"""Character settings blob."""
|
|
1312
|
+
return self._data.get("csettings", {})
|
|
1313
|
+
|
|
1314
|
+
@property
|
|
1315
|
+
def race(self) -> str | None:
|
|
1316
|
+
"""Race label."""
|
|
1317
|
+
val = self._data.get("race")
|
|
1318
|
+
return str(val) if val is not None else None
|
|
1319
|
+
|
|
1320
|
+
@property
|
|
1321
|
+
def background(self) -> str | None:
|
|
1322
|
+
"""Background name."""
|
|
1323
|
+
val = self._data.get("background")
|
|
1324
|
+
return str(val) if val is not None else None
|
|
1325
|
+
|
|
1326
|
+
@property
|
|
1327
|
+
def owner(self) -> int | None:
|
|
1328
|
+
"""Discord user id of the owning account."""
|
|
1329
|
+
raw = self._data.get("owner")
|
|
1330
|
+
return int(raw) if raw is not None else None
|
|
1331
|
+
|
|
1332
|
+
@property
|
|
1333
|
+
def upstream(self) -> str | None:
|
|
1334
|
+
"""Upstream character id (e.g., Beyond character slug)."""
|
|
1335
|
+
val = self._data.get("upstream")
|
|
1336
|
+
return str(val) if val is not None else None
|
|
1337
|
+
|
|
1338
|
+
@property
|
|
1339
|
+
def sheet_type(self) -> str | None:
|
|
1340
|
+
"""Source sheet provider (beyond, custom, etc.)."""
|
|
1341
|
+
val = self._data.get("sheet_type")
|
|
1342
|
+
return str(val) if val is not None else None
|
|
1343
|
+
|
|
1344
|
+
@property
|
|
1345
|
+
def cvars(self) -> Mapping[str, Any]:
|
|
1346
|
+
"""Character variables (string values)."""
|
|
1347
|
+
return dict(self._data.get("cvars") or {})
|
|
1348
|
+
|
|
1349
|
+
def get_cvar(self, name: str, default: Any = None) -> Optional[str]:
|
|
1350
|
+
"""Fetch a character variable, returning `default` if missing."""
|
|
1351
|
+
cvars = self._data.get("cvars") or {}
|
|
1352
|
+
val = cvars.get(str(name), default)
|
|
1353
|
+
return str(val) if val is not None else default
|
|
1354
|
+
|
|
1355
|
+
def set_cvar(self, name: str, val: str) -> Optional[str]:
|
|
1356
|
+
"""Sets a character variable. Avrae stores cvars as strings."""
|
|
1357
|
+
str_val = str(val) if val is not None else None
|
|
1358
|
+
self._data.setdefault("cvars", {})[str(name)] = str_val
|
|
1359
|
+
return str_val
|
|
1360
|
+
|
|
1361
|
+
def set_cvar_nx(self, name: str, val: str) -> str:
|
|
1362
|
+
"""Set a character variable only if it does not already exist."""
|
|
1363
|
+
cvars = self._data.setdefault("cvars", {})
|
|
1364
|
+
str_val = str(val) if val is not None else None
|
|
1365
|
+
return cvars.setdefault(str(name), str_val)
|
|
1366
|
+
|
|
1367
|
+
def delete_cvar(self, name: str) -> Optional[str]:
|
|
1368
|
+
"""Delete a character variable and return its old value if present."""
|
|
1369
|
+
return self._data.setdefault("cvars", {}).pop(str(name), None)
|
|
1370
|
+
|
|
1371
|
+
@property
|
|
1372
|
+
def consumables(self) -> list[AliasCustomCounter]:
|
|
1373
|
+
"""Custom counters/consumables on the character."""
|
|
1374
|
+
return [AliasCustomCounter(v) for v in self._consumable_map().values()]
|
|
1375
|
+
|
|
1376
|
+
def cc(self, name: str) -> AliasCustomCounter:
|
|
1377
|
+
"""Get (or create placeholder for) a custom counter by name."""
|
|
1378
|
+
return AliasCustomCounter(self._consumable_map()[str(name)])
|
|
1379
|
+
|
|
1380
|
+
def get_cc(self, name: str) -> int:
|
|
1381
|
+
"""Current value of a custom counter."""
|
|
1382
|
+
return self.cc(name).value
|
|
1383
|
+
|
|
1384
|
+
def get_cc_max(self, name: str) -> int:
|
|
1385
|
+
"""Maximum value for a custom counter."""
|
|
1386
|
+
return self.cc(name).max
|
|
1387
|
+
|
|
1388
|
+
def get_cc_min(self, name: str) -> int:
|
|
1389
|
+
"""Minimum value for a custom counter."""
|
|
1390
|
+
return self.cc(name).min
|
|
1391
|
+
|
|
1392
|
+
def set_cc(self, name: str, value: int | None = None, maximum: int | None = None, minimum: int | None = None) -> int:
|
|
1393
|
+
"""Set value/max/min for a custom counter."""
|
|
1394
|
+
con = self._consumable_map().setdefault(str(name), {"name": str(name)})
|
|
1395
|
+
if value is not None:
|
|
1396
|
+
con["value"] = int(value)
|
|
1397
|
+
if maximum is not None:
|
|
1398
|
+
con["max"] = int(maximum)
|
|
1399
|
+
if minimum is not None:
|
|
1400
|
+
con["min"] = int(minimum)
|
|
1401
|
+
return _safe_int(con.get("value"), 0)
|
|
1402
|
+
|
|
1403
|
+
def mod_cc(self, name: str, val: int, strict: bool = False) -> int:
|
|
1404
|
+
"""Modify a custom counter by `val` (optionally enforcing bounds)."""
|
|
1405
|
+
counter = self.cc(name)
|
|
1406
|
+
return counter.mod(val, strict)
|
|
1407
|
+
|
|
1408
|
+
def delete_cc(self, name: str) -> Any:
|
|
1409
|
+
"""Remove a custom counter and return its payload."""
|
|
1410
|
+
return self._consumable_map().pop(str(name), None)
|
|
1411
|
+
|
|
1412
|
+
def create_cc_nx(
|
|
1413
|
+
self,
|
|
1414
|
+
name: str,
|
|
1415
|
+
minVal: str | None = None,
|
|
1416
|
+
maxVal: str | None = None,
|
|
1417
|
+
reset: str | None = None,
|
|
1418
|
+
dispType: str | None = None,
|
|
1419
|
+
reset_to: str | None = None,
|
|
1420
|
+
reset_by: str | None = None,
|
|
1421
|
+
title: str | None = None,
|
|
1422
|
+
desc: str | None = None,
|
|
1423
|
+
initial_value: str | None = None,
|
|
1424
|
+
) -> AliasCustomCounter:
|
|
1425
|
+
"""Create a custom counter if missing, preserving existing ones."""
|
|
1426
|
+
if not self.cc_exists(name):
|
|
1427
|
+
self.create_cc(
|
|
1428
|
+
name,
|
|
1429
|
+
minVal=minVal,
|
|
1430
|
+
maxVal=maxVal,
|
|
1431
|
+
reset=reset,
|
|
1432
|
+
dispType=dispType,
|
|
1433
|
+
reset_to=reset_to,
|
|
1434
|
+
reset_by=reset_by,
|
|
1435
|
+
title=title,
|
|
1436
|
+
desc=desc,
|
|
1437
|
+
initial_value=initial_value,
|
|
1438
|
+
)
|
|
1439
|
+
return self.cc(name)
|
|
1440
|
+
|
|
1441
|
+
def create_cc(
|
|
1442
|
+
self,
|
|
1443
|
+
name: str,
|
|
1444
|
+
minVal: str | None = None,
|
|
1445
|
+
maxVal: str | None = None,
|
|
1446
|
+
reset: str | None = None,
|
|
1447
|
+
dispType: str | None = None,
|
|
1448
|
+
reset_to: str | None = None,
|
|
1449
|
+
reset_by: str | None = None,
|
|
1450
|
+
title: str | None = None,
|
|
1451
|
+
desc: str | None = None,
|
|
1452
|
+
initial_value: str | None = None,
|
|
1453
|
+
) -> AliasCustomCounter:
|
|
1454
|
+
"""Create or overwrite a custom counter."""
|
|
1455
|
+
payload = {
|
|
1456
|
+
"name": str(name),
|
|
1457
|
+
"min": _safe_int(minVal, -(2**31)) if minVal is not None else -(2**31),
|
|
1458
|
+
"max": _safe_int(maxVal, 2**31 - 1) if maxVal is not None else 2**31 - 1,
|
|
1459
|
+
"reset_on": reset,
|
|
1460
|
+
"display_type": dispType,
|
|
1461
|
+
"reset_to": _safe_int(reset_to) if reset_to is not None else None,
|
|
1462
|
+
"reset_by": reset_by,
|
|
1463
|
+
"title": title,
|
|
1464
|
+
"desc": desc,
|
|
1465
|
+
"value": _safe_int(initial_value, 0) if initial_value is not None else 0,
|
|
1466
|
+
}
|
|
1467
|
+
self._consumable_map()[str(name)] = payload
|
|
1468
|
+
return AliasCustomCounter(payload)
|
|
1469
|
+
|
|
1470
|
+
def edit_cc(
|
|
1471
|
+
self,
|
|
1472
|
+
name: str,
|
|
1473
|
+
minVal: Any = UNSET,
|
|
1474
|
+
maxVal: Any = UNSET,
|
|
1475
|
+
reset: Any = UNSET,
|
|
1476
|
+
dispType: Any = UNSET,
|
|
1477
|
+
reset_to: Any = UNSET,
|
|
1478
|
+
reset_by: Any = UNSET,
|
|
1479
|
+
title: Any = UNSET,
|
|
1480
|
+
desc: Any = UNSET,
|
|
1481
|
+
new_name: str | None = None,
|
|
1482
|
+
) -> AliasCustomCounter:
|
|
1483
|
+
"""Edit fields on an existing custom counter."""
|
|
1484
|
+
counter = dict(self._consumable_map().get(str(name)) or {"name": str(name)})
|
|
1485
|
+
for key, val in (
|
|
1486
|
+
("min", minVal),
|
|
1487
|
+
("max", maxVal),
|
|
1488
|
+
("reset_on", reset),
|
|
1489
|
+
("display_type", dispType),
|
|
1490
|
+
("reset_to", reset_to),
|
|
1491
|
+
("reset_by", reset_by),
|
|
1492
|
+
("title", title),
|
|
1493
|
+
("desc", desc),
|
|
1494
|
+
):
|
|
1495
|
+
if val is not UNSET:
|
|
1496
|
+
counter[key] = val
|
|
1497
|
+
counter["name"] = str(new_name) if new_name else counter.get("name", str(name))
|
|
1498
|
+
self._consumable_map().pop(str(name), None)
|
|
1499
|
+
self._consumable_map()[counter["name"]] = counter
|
|
1500
|
+
return AliasCustomCounter(counter)
|
|
1501
|
+
|
|
1502
|
+
def cc_exists(self, name: str) -> bool:
|
|
1503
|
+
"""Return True if a custom counter with the name exists."""
|
|
1504
|
+
return str(name) in self._consumable_map()
|
|
1505
|
+
|
|
1506
|
+
def cc_str(self, name: str) -> str:
|
|
1507
|
+
"""String form of a custom counter."""
|
|
1508
|
+
return str(self.cc(name))
|
|
1509
|
+
|
|
1510
|
+
@property
|
|
1511
|
+
def death_saves(self) -> AliasDeathSaves:
|
|
1512
|
+
"""Death save successes/failures."""
|
|
1513
|
+
return AliasDeathSaves(self._data.get("death_saves") or {})
|
|
1514
|
+
|
|
1515
|
+
@property
|
|
1516
|
+
def description(self) -> str | None:
|
|
1517
|
+
"""Character description/biography."""
|
|
1518
|
+
val = self._data.get("description")
|
|
1519
|
+
return str(val) if val is not None else None
|
|
1520
|
+
|
|
1521
|
+
@property
|
|
1522
|
+
def image(self) -> str | None:
|
|
1523
|
+
"""Avatar or sheet image URL."""
|
|
1524
|
+
val = self._data.get("image")
|
|
1525
|
+
return str(val) if val is not None else None
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
# === Combat API ===
|
|
1529
|
+
MAX_COMBAT_METADATA_SIZE = 100000
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
@dataclass
|
|
1533
|
+
class SimpleEffect(_DirMixin):
|
|
1534
|
+
_data: MutableMapping[str, Any]
|
|
1535
|
+
ATTRS: ClassVar[list[str]] = [
|
|
1536
|
+
"name",
|
|
1537
|
+
"duration",
|
|
1538
|
+
"remaining",
|
|
1539
|
+
"effect",
|
|
1540
|
+
"attacks",
|
|
1541
|
+
"buttons",
|
|
1542
|
+
"conc",
|
|
1543
|
+
"desc",
|
|
1544
|
+
"ticks_on_end",
|
|
1545
|
+
"combatant_name",
|
|
1546
|
+
"parent",
|
|
1547
|
+
"children",
|
|
1548
|
+
]
|
|
1549
|
+
METHODS: ClassVar[list[str]] = ["set_parent"]
|
|
1550
|
+
|
|
1551
|
+
@property
|
|
1552
|
+
def name(self) -> str:
|
|
1553
|
+
return str(self._data.get("name", "Effect"))
|
|
1554
|
+
|
|
1555
|
+
@property
|
|
1556
|
+
def duration(self) -> int | None:
|
|
1557
|
+
raw = self._data.get("duration")
|
|
1558
|
+
return int(raw) if raw is not None else None
|
|
1559
|
+
|
|
1560
|
+
@property
|
|
1561
|
+
def remaining(self) -> int | None:
|
|
1562
|
+
raw = self._data.get("remaining")
|
|
1563
|
+
return int(raw) if raw is not None else None
|
|
1564
|
+
|
|
1565
|
+
@property
|
|
1566
|
+
def effect(self) -> Mapping[str, Any]:
|
|
1567
|
+
return self._data.get("effects") or self._data.get("effect") or {}
|
|
1568
|
+
|
|
1569
|
+
@property
|
|
1570
|
+
def attacks(self) -> list[Mapping[str, Any]]:
|
|
1571
|
+
return list(self._data.get("attacks") or [])
|
|
1572
|
+
|
|
1573
|
+
@property
|
|
1574
|
+
def buttons(self) -> list[Mapping[str, Any]]:
|
|
1575
|
+
return list(self._data.get("buttons") or [])
|
|
1576
|
+
|
|
1577
|
+
@property
|
|
1578
|
+
def conc(self) -> bool:
|
|
1579
|
+
return bool(self._data.get("conc") or self._data.get("concentration", False))
|
|
1580
|
+
|
|
1581
|
+
@property
|
|
1582
|
+
def desc(self) -> str | None:
|
|
1583
|
+
val = self._data.get("desc")
|
|
1584
|
+
return str(val) if val is not None else None
|
|
1585
|
+
|
|
1586
|
+
@property
|
|
1587
|
+
def ticks_on_end(self) -> bool:
|
|
1588
|
+
return bool(self._data.get("ticks_on_end") or self._data.get("end_on_turn_end", False))
|
|
1589
|
+
|
|
1590
|
+
@property
|
|
1591
|
+
def combatant_name(self) -> str | None:
|
|
1592
|
+
val = self._data.get("combatant_name")
|
|
1593
|
+
return str(val) if val is not None else None
|
|
1594
|
+
|
|
1595
|
+
@property
|
|
1596
|
+
def parent(self) -> "SimpleEffect" | None:
|
|
1597
|
+
parent = self._data.get("parent")
|
|
1598
|
+
return SimpleEffect(parent) if parent else None
|
|
1599
|
+
|
|
1600
|
+
@property
|
|
1601
|
+
def children(self) -> list["SimpleEffect"]:
|
|
1602
|
+
return [SimpleEffect(c) for c in self._data.get("children", [])]
|
|
1603
|
+
|
|
1604
|
+
def set_parent(self, parent: "SimpleEffect") -> None:
|
|
1605
|
+
if not isinstance(parent, SimpleEffect):
|
|
1606
|
+
raise TypeError("Parent effect must be a SimpleEffect.")
|
|
1607
|
+
self._data["parent"] = parent._data
|
|
1608
|
+
|
|
1609
|
+
def __getitem__(self, item: str) -> Any:
|
|
1610
|
+
return getattr(self, str(item))
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
@dataclass
|
|
1614
|
+
class SimpleCombatant(AliasStatBlock):
|
|
1615
|
+
ATTRS: ClassVar[list[str]] = AliasStatBlock.ATTRS + [
|
|
1616
|
+
"effects",
|
|
1617
|
+
"init",
|
|
1618
|
+
"initmod",
|
|
1619
|
+
"type",
|
|
1620
|
+
"note",
|
|
1621
|
+
"controller",
|
|
1622
|
+
"group",
|
|
1623
|
+
"race",
|
|
1624
|
+
"monster_name",
|
|
1625
|
+
"is_hidden",
|
|
1626
|
+
"id",
|
|
1627
|
+
]
|
|
1628
|
+
METHODS: ClassVar[list[str]] = AliasStatBlock.METHODS + [
|
|
1629
|
+
"save",
|
|
1630
|
+
"damage",
|
|
1631
|
+
"set_ac",
|
|
1632
|
+
"set_maxhp",
|
|
1633
|
+
"set_init",
|
|
1634
|
+
"set_name",
|
|
1635
|
+
"set_group",
|
|
1636
|
+
"set_note",
|
|
1637
|
+
"get_effect",
|
|
1638
|
+
"add_effect",
|
|
1639
|
+
"remove_effect",
|
|
1640
|
+
]
|
|
1641
|
+
|
|
1642
|
+
def __post_init__(self) -> None:
|
|
1643
|
+
super().__post_init__()
|
|
1644
|
+
self._data.setdefault("type", "combatant")
|
|
1645
|
+
|
|
1646
|
+
@property
|
|
1647
|
+
def id(self) -> str | None:
|
|
1648
|
+
"""Unique combatant id."""
|
|
1649
|
+
val = self._data.get("id")
|
|
1650
|
+
return str(val) if val is not None else None
|
|
1651
|
+
|
|
1652
|
+
@property
|
|
1653
|
+
def effects(self) -> list[SimpleEffect]:
|
|
1654
|
+
"""Active effects on the combatant."""
|
|
1655
|
+
return [SimpleEffect(e) for e in self._data.get("effects", [])]
|
|
1656
|
+
|
|
1657
|
+
@property
|
|
1658
|
+
def init(self) -> int:
|
|
1659
|
+
"""Initiative score."""
|
|
1660
|
+
return _safe_int(self._data.get("init"), 0)
|
|
1661
|
+
|
|
1662
|
+
@property
|
|
1663
|
+
def initmod(self) -> int:
|
|
1664
|
+
"""Initiative modifier."""
|
|
1665
|
+
return _safe_int(self._data.get("initmod"), 0)
|
|
1666
|
+
|
|
1667
|
+
@property
|
|
1668
|
+
def type(self) -> str:
|
|
1669
|
+
"""Combatant type (combatant/group)."""
|
|
1670
|
+
return str(self._data.get("type", "combatant"))
|
|
1671
|
+
|
|
1672
|
+
@property
|
|
1673
|
+
def note(self) -> str | None:
|
|
1674
|
+
"""DM note attached to the combatant."""
|
|
1675
|
+
val = self._data.get("note")
|
|
1676
|
+
return str(val) if val is not None else None
|
|
1677
|
+
|
|
1678
|
+
@property
|
|
1679
|
+
def controller(self) -> int | None:
|
|
1680
|
+
"""Discord id of the controller (if any)."""
|
|
1681
|
+
raw = self._data.get("controller")
|
|
1682
|
+
return int(raw) if raw is not None else None
|
|
1683
|
+
|
|
1684
|
+
@property
|
|
1685
|
+
def group(self) -> str | None:
|
|
1686
|
+
"""Group name the combatant belongs to."""
|
|
1687
|
+
val = self._data.get("group")
|
|
1688
|
+
return str(val) if val is not None else None
|
|
1689
|
+
|
|
1690
|
+
@property
|
|
1691
|
+
def race(self) -> str | None:
|
|
1692
|
+
"""Race/creature type label."""
|
|
1693
|
+
val = self._data.get("race")
|
|
1694
|
+
return str(val) if val is not None else None
|
|
1695
|
+
|
|
1696
|
+
@property
|
|
1697
|
+
def monster_name(self) -> str | None:
|
|
1698
|
+
"""Monster name if this combatant represents a monster."""
|
|
1699
|
+
val = self._data.get("monster_name")
|
|
1700
|
+
return str(val) if val is not None else None
|
|
1701
|
+
|
|
1702
|
+
@property
|
|
1703
|
+
def is_hidden(self) -> bool:
|
|
1704
|
+
"""Whether the combatant is hidden in the tracker."""
|
|
1705
|
+
return bool(self._data.get("is_hidden", False))
|
|
1706
|
+
|
|
1707
|
+
def save(self, ability: str, adv: bool | None = None) -> SimpleRollResult:
|
|
1708
|
+
"""Roll a saving throw using the combatant's stats."""
|
|
1709
|
+
roll_expr = self.saves.get(ability).d20(base_adv=adv)
|
|
1710
|
+
try:
|
|
1711
|
+
roll_result = d20.roll(roll_expr)
|
|
1712
|
+
except Exception:
|
|
1713
|
+
roll_result = d20.roll("0")
|
|
1714
|
+
return SimpleRollResult(roll_result)
|
|
1715
|
+
|
|
1716
|
+
def damage(
|
|
1717
|
+
self,
|
|
1718
|
+
dice_str: str,
|
|
1719
|
+
crit: bool = False,
|
|
1720
|
+
d=None,
|
|
1721
|
+
c=None,
|
|
1722
|
+
critdice: int = 0,
|
|
1723
|
+
overheal: bool = False,
|
|
1724
|
+
) -> dict[str, Any]:
|
|
1725
|
+
"""Apply damage expression to the combatant and return the roll breakdown."""
|
|
1726
|
+
expr = str(dice_str)
|
|
1727
|
+
if crit:
|
|
1728
|
+
expr = f"({expr})*2"
|
|
1729
|
+
if d is not None:
|
|
1730
|
+
expr = f"({expr})+({d})"
|
|
1731
|
+
if c is not None and crit:
|
|
1732
|
+
expr = f"({expr})+({c})"
|
|
1733
|
+
if critdice:
|
|
1734
|
+
expr = f"({expr})+{int(critdice)}"
|
|
1735
|
+
try:
|
|
1736
|
+
roll_result = d20.roll(expr)
|
|
1737
|
+
except Exception:
|
|
1738
|
+
roll_result = d20.roll("0")
|
|
1739
|
+
label = "Damage (CRIT!)" if crit else "Damage"
|
|
1740
|
+
return {"damage": f"**{label}**: {roll_result}", "total": roll_result.total, "roll": SimpleRollResult(roll_result)}
|
|
1741
|
+
|
|
1742
|
+
def set_ac(self, ac: int) -> None:
|
|
1743
|
+
"""Set armor class."""
|
|
1744
|
+
self._data["ac"] = int(ac)
|
|
1745
|
+
|
|
1746
|
+
def set_maxhp(self, maxhp: int) -> None:
|
|
1747
|
+
"""Set maximum HP."""
|
|
1748
|
+
self._data["max_hp"] = int(maxhp)
|
|
1749
|
+
|
|
1750
|
+
def set_init(self, init: int) -> None:
|
|
1751
|
+
"""Set initiative score."""
|
|
1752
|
+
self._data["init"] = int(init)
|
|
1753
|
+
|
|
1754
|
+
def set_name(self, name: str) -> None:
|
|
1755
|
+
"""Rename the combatant."""
|
|
1756
|
+
self._data["name"] = str(name)
|
|
1757
|
+
|
|
1758
|
+
def set_group(self, group: str | None) -> str | None:
|
|
1759
|
+
"""Assign the combatant to a group."""
|
|
1760
|
+
self._data["group"] = str(group) if group is not None else None
|
|
1761
|
+
return self.group
|
|
1762
|
+
|
|
1763
|
+
def set_note(self, note: str) -> None:
|
|
1764
|
+
"""Attach/update a DM note."""
|
|
1765
|
+
self._data["note"] = str(note) if note is not None else None
|
|
1766
|
+
|
|
1767
|
+
def get_effect(self, name: str, strict: bool = False) -> SimpleEffect | None:
|
|
1768
|
+
"""Find an effect by name (optionally requiring exact match)."""
|
|
1769
|
+
name_lower = str(name).lower()
|
|
1770
|
+
for effect in self.effects:
|
|
1771
|
+
if strict and effect.name.lower() == name_lower:
|
|
1772
|
+
return effect
|
|
1773
|
+
if not strict and name_lower in effect.name.lower():
|
|
1774
|
+
return effect
|
|
1775
|
+
return None
|
|
1776
|
+
|
|
1777
|
+
def add_effect(
|
|
1778
|
+
self,
|
|
1779
|
+
name: str,
|
|
1780
|
+
args: str | None = None,
|
|
1781
|
+
duration: int | None = None,
|
|
1782
|
+
concentration: bool = False,
|
|
1783
|
+
parent: SimpleEffect | None = None,
|
|
1784
|
+
end: bool = False,
|
|
1785
|
+
desc: str | None = None,
|
|
1786
|
+
passive_effects: dict | None = None,
|
|
1787
|
+
attacks: list[dict] | None = None,
|
|
1788
|
+
buttons: list[dict] | None = None,
|
|
1789
|
+
tick_on_combatant_id: str | None = None,
|
|
1790
|
+
) -> SimpleEffect:
|
|
1791
|
+
"""Add a new effect to the combatant."""
|
|
1792
|
+
duration_val = int(duration) if duration is not None else None
|
|
1793
|
+
desc_val = str(desc) if desc is not None else None
|
|
1794
|
+
payload: dict[str, Any] = {
|
|
1795
|
+
"name": str(name),
|
|
1796
|
+
"duration": duration_val,
|
|
1797
|
+
"remaining": duration_val,
|
|
1798
|
+
"args": str(args) if args is not None else None,
|
|
1799
|
+
"desc": desc_val,
|
|
1800
|
+
"concentration": bool(concentration),
|
|
1801
|
+
"conc": bool(concentration),
|
|
1802
|
+
"ticks_on_end": end,
|
|
1803
|
+
"effect": dict(passive_effects or {}),
|
|
1804
|
+
"attacks": list(attacks or []),
|
|
1805
|
+
"buttons": list(buttons or []),
|
|
1806
|
+
"combatant_name": self.name,
|
|
1807
|
+
}
|
|
1808
|
+
if tick_on_combatant_id is not None:
|
|
1809
|
+
payload["tick_on_combatant_id"] = str(tick_on_combatant_id)
|
|
1810
|
+
if parent is not None:
|
|
1811
|
+
if not isinstance(parent, SimpleEffect):
|
|
1812
|
+
raise TypeError("Parent effect must be a SimpleEffect.")
|
|
1813
|
+
payload["parent"] = parent._data
|
|
1814
|
+
effects = self._data.setdefault("effects", [])
|
|
1815
|
+
existing = self.get_effect(name, strict=True)
|
|
1816
|
+
if existing:
|
|
1817
|
+
try:
|
|
1818
|
+
effects.remove(existing._data)
|
|
1819
|
+
except ValueError:
|
|
1820
|
+
pass
|
|
1821
|
+
effects.append(payload)
|
|
1822
|
+
return SimpleEffect(payload)
|
|
1823
|
+
|
|
1824
|
+
def remove_effect(self, name: str, strict: bool = False) -> None:
|
|
1825
|
+
"""Remove an effect by name."""
|
|
1826
|
+
effect = self.get_effect(name, strict)
|
|
1827
|
+
if effect:
|
|
1828
|
+
try:
|
|
1829
|
+
self._data.setdefault("effects", []).remove(effect._data)
|
|
1830
|
+
except ValueError:
|
|
1831
|
+
pass
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
@dataclass
|
|
1835
|
+
class SimpleGroup(_DirMixin):
|
|
1836
|
+
_data: MutableMapping[str, Any] = field(default_factory=dict)
|
|
1837
|
+
ATTRS: ClassVar[list[str]] = ["combatants", "type", "init", "name", "id"]
|
|
1838
|
+
METHODS: ClassVar[list[str]] = ["get_combatant", "set_init"]
|
|
1839
|
+
|
|
1840
|
+
def __post_init__(self) -> None:
|
|
1841
|
+
super().__post_init__()
|
|
1842
|
+
self._data.setdefault("type", "group")
|
|
1843
|
+
|
|
1844
|
+
@property
|
|
1845
|
+
def combatants(self) -> list[SimpleCombatant]:
|
|
1846
|
+
"""Members of the group."""
|
|
1847
|
+
return [SimpleCombatant(c) for c in self._data.get("combatants", [])]
|
|
1848
|
+
|
|
1849
|
+
@property
|
|
1850
|
+
def type(self) -> str:
|
|
1851
|
+
"""Group type identifier (always 'group')."""
|
|
1852
|
+
return str(self._data.get("type", "group"))
|
|
1853
|
+
|
|
1854
|
+
@property
|
|
1855
|
+
def init(self) -> int:
|
|
1856
|
+
"""Initiative score for the group."""
|
|
1857
|
+
return _safe_int(self._data.get("init"), 0)
|
|
1858
|
+
|
|
1859
|
+
@property
|
|
1860
|
+
def name(self) -> str:
|
|
1861
|
+
"""Group name."""
|
|
1862
|
+
return str(self._data.get("name", "Group"))
|
|
1863
|
+
|
|
1864
|
+
@property
|
|
1865
|
+
def id(self) -> str | None:
|
|
1866
|
+
"""Group id."""
|
|
1867
|
+
val = self._data.get("id")
|
|
1868
|
+
return str(val) if val is not None else None
|
|
1869
|
+
|
|
1870
|
+
def get_combatant(self, name: str, strict: bool | None = None) -> SimpleCombatant | None:
|
|
1871
|
+
"""Find a combatant within the group."""
|
|
1872
|
+
name_lower = str(name).lower()
|
|
1873
|
+
for combatant in self.combatants:
|
|
1874
|
+
if strict is True and combatant.name.lower() == name_lower:
|
|
1875
|
+
return combatant
|
|
1876
|
+
if strict is None and combatant.name.lower() == name_lower:
|
|
1877
|
+
return combatant
|
|
1878
|
+
if strict is False and name_lower in combatant.name.lower():
|
|
1879
|
+
return combatant
|
|
1880
|
+
if strict is None:
|
|
1881
|
+
for combatant in self.combatants:
|
|
1882
|
+
if name_lower in combatant.name.lower():
|
|
1883
|
+
return combatant
|
|
1884
|
+
return None
|
|
1885
|
+
|
|
1886
|
+
def set_init(self, init: int) -> None:
|
|
1887
|
+
self._data["init"] = int(init)
|
|
1888
|
+
|
|
1889
|
+
def __getitem__(self, item: str) -> Any:
|
|
1890
|
+
return getattr(self, str(item))
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
@dataclass
|
|
1894
|
+
class SimpleCombat(_DirMixin):
|
|
1895
|
+
_data: MutableMapping[str, Any] = field(default_factory=dict)
|
|
1896
|
+
ATTRS: ClassVar[list[str]] = ["combatants", "groups", "me", "current", "name", "round_num", "turn_num", "metadata"]
|
|
1897
|
+
METHODS: ClassVar[list[str]] = ["get_combatant", "get_group", "set_metadata", "get_metadata", "delete_metadata", "set_round", "end_round"]
|
|
1898
|
+
|
|
1899
|
+
@property
|
|
1900
|
+
def combatants(self) -> list[SimpleCombatant]:
|
|
1901
|
+
"""All combatants in the encounter."""
|
|
1902
|
+
return [SimpleCombatant(c) for c in self._data.get("combatants", [])]
|
|
1903
|
+
|
|
1904
|
+
@property
|
|
1905
|
+
def groups(self) -> list[SimpleGroup]:
|
|
1906
|
+
"""Combatant groups in the encounter."""
|
|
1907
|
+
return [SimpleGroup(g) for g in self._data.get("groups", [])]
|
|
1908
|
+
|
|
1909
|
+
@property
|
|
1910
|
+
def me(self) -> SimpleCombatant | None:
|
|
1911
|
+
"""The player's combatant if present."""
|
|
1912
|
+
me_data = self._data.get("me")
|
|
1913
|
+
return SimpleCombatant(me_data) if me_data is not None else None
|
|
1914
|
+
|
|
1915
|
+
@property
|
|
1916
|
+
def current(self) -> SimpleCombatant | SimpleGroup | None:
|
|
1917
|
+
"""Current turn holder (combatant or group)."""
|
|
1918
|
+
cur = self._data.get("current")
|
|
1919
|
+
if cur is None:
|
|
1920
|
+
return None
|
|
1921
|
+
if cur.get("type") == "group":
|
|
1922
|
+
return SimpleGroup(cur)
|
|
1923
|
+
return SimpleCombatant(cur)
|
|
1924
|
+
|
|
1925
|
+
@property
|
|
1926
|
+
def name(self) -> str | None:
|
|
1927
|
+
"""Name of the combat encounter."""
|
|
1928
|
+
val = self._data.get("name")
|
|
1929
|
+
return str(val) if val is not None else None
|
|
1930
|
+
|
|
1931
|
+
@property
|
|
1932
|
+
def round_num(self) -> int:
|
|
1933
|
+
"""Current round number."""
|
|
1934
|
+
return _safe_int(self._data.get("round_num"), 1)
|
|
1935
|
+
|
|
1936
|
+
@property
|
|
1937
|
+
def turn_num(self) -> int:
|
|
1938
|
+
"""Current turn number within the round."""
|
|
1939
|
+
return _safe_int(self._data.get("turn_num"), 1)
|
|
1940
|
+
|
|
1941
|
+
@property
|
|
1942
|
+
def metadata(self) -> MutableMapping[str, Any]:
|
|
1943
|
+
"""Free-form metadata key/value store for the combat."""
|
|
1944
|
+
return self._data.setdefault("metadata", {})
|
|
1945
|
+
|
|
1946
|
+
def get_combatant(self, name: str, strict: bool | None = None) -> SimpleCombatant | None:
|
|
1947
|
+
"""Find a combatant by name (strict, substring, or fuzzy)."""
|
|
1948
|
+
name_lower = str(name).lower()
|
|
1949
|
+
for combatant in self.combatants:
|
|
1950
|
+
if strict is True and combatant.name.lower() == name_lower:
|
|
1951
|
+
return combatant
|
|
1952
|
+
if strict is None and combatant.name.lower() == name_lower:
|
|
1953
|
+
return combatant
|
|
1954
|
+
if strict is False and name_lower in combatant.name.lower():
|
|
1955
|
+
return combatant
|
|
1956
|
+
if strict is None:
|
|
1957
|
+
for combatant in self.combatants:
|
|
1958
|
+
if name_lower in combatant.name.lower():
|
|
1959
|
+
return combatant
|
|
1960
|
+
return None
|
|
1961
|
+
|
|
1962
|
+
def get_group(self, name: str, strict: bool | None = None) -> SimpleGroup | None:
|
|
1963
|
+
"""Find a combatant group by name."""
|
|
1964
|
+
name_lower = str(name).lower()
|
|
1965
|
+
for group in self.groups:
|
|
1966
|
+
if strict is True and group.name.lower() == name_lower:
|
|
1967
|
+
return group
|
|
1968
|
+
if strict is None and group.name.lower() == name_lower:
|
|
1969
|
+
return group
|
|
1970
|
+
if strict is False and name_lower in group.name.lower():
|
|
1971
|
+
return group
|
|
1972
|
+
if strict is None:
|
|
1973
|
+
for group in self.groups:
|
|
1974
|
+
if name_lower in group.name.lower():
|
|
1975
|
+
return group
|
|
1976
|
+
return None
|
|
1977
|
+
|
|
1978
|
+
def set_metadata(self, k: str, v: str) -> None:
|
|
1979
|
+
"""Set a metadata key/value pair, enforcing Avrae size limits."""
|
|
1980
|
+
key = str(k)
|
|
1981
|
+
value = str(v)
|
|
1982
|
+
existing = {str(ke): str(va) for ke, va in self.metadata.items() if str(ke) != key}
|
|
1983
|
+
proposed_size = sum(len(ke) + len(va) for ke, va in existing.items()) + len(key) + len(value)
|
|
1984
|
+
if proposed_size > MAX_COMBAT_METADATA_SIZE:
|
|
1985
|
+
raise ValueError("Combat metadata is too large")
|
|
1986
|
+
self.metadata[key] = value
|
|
1987
|
+
|
|
1988
|
+
def get_metadata(self, k: str, default: Any = None) -> Any:
|
|
1989
|
+
return self.metadata.get(str(k), default)
|
|
1990
|
+
|
|
1991
|
+
def delete_metadata(self, k: str) -> Any:
|
|
1992
|
+
"""Delete a metadata key."""
|
|
1993
|
+
return self.metadata.pop(str(k), None)
|
|
1994
|
+
|
|
1995
|
+
def set_round(self, round_num: int) -> None:
|
|
1996
|
+
"""Advance combat to the specified round number."""
|
|
1997
|
+
self._data["round_num"] = int(round_num)
|
|
1998
|
+
|
|
1999
|
+
def end_round(self) -> None:
|
|
2000
|
+
"""Increment round number and reset turn counter."""
|
|
2001
|
+
self._data["turn_num"] = 0
|
|
2002
|
+
self._data["round_num"] = self.round_num + 1
|
|
2003
|
+
|
|
2004
|
+
def __getattr__(self, item: str) -> Any:
|
|
2005
|
+
return self._data.get(item)
|
|
2006
|
+
|
|
2007
|
+
def __getitem__(self, item: str) -> Any:
|
|
2008
|
+
return getattr(self, str(item))
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
# Backwards-compatible aliases for existing consumers
|
|
2012
|
+
CombatantAPI = SimpleCombatant
|
|
2013
|
+
SimpleEffectAPI = SimpleEffect
|
|
2014
|
+
SimpleGroupAPI = SimpleGroup
|
|
2015
|
+
CombatAPI = SimpleCombat
|