valentina-python-client 3.3.0__tar.gz → 3.4.0__tar.gz

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.
Files changed (86) hide show
  1. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/PKG-INFO +5 -4
  2. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/pyproject.toml +16 -14
  3. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/pyproject.toml.orig +17 -15
  4. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/__init__.py +1 -1
  5. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/character_traits.py +224 -1
  6. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/characters.py +96 -0
  7. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/options.py +7 -2
  8. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/constants.py +5 -0
  9. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/endpoints.py +6 -0
  10. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/exceptions.py +3 -1
  11. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/__init__.py +24 -0
  12. valentina_python_client-3.4.0/src/vclient/models/character_trait.py +286 -0
  13. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/characters.py +10 -1
  14. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/companies.py +3 -0
  15. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/shared.py +8 -0
  16. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/character_traits.py +241 -1
  17. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/characters.py +103 -0
  18. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/options.py +7 -2
  19. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/testing/__init__.py +10 -0
  20. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/testing/_factories.py +132 -6
  21. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/testing/_router.py +17 -2
  22. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/testing/_routes.py +26 -0
  23. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/validate_constants.py +3 -0
  24. valentina_python_client-3.3.0/src/vclient/models/character_trait.py +0 -117
  25. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/LICENSE +0 -0
  26. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/README.md +0 -0
  27. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_codegen.py +0 -0
  28. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/__init__.py +0 -0
  29. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/client.py +0 -0
  30. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/registry.py +0 -0
  31. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/__init__.py +0 -0
  32. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/_audit_params.py +0 -0
  33. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/base.py +0 -0
  34. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/campaign_book_chapters.py +0 -0
  35. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/campaign_books.py +0 -0
  36. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/campaigns.py +0 -0
  37. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/character_autogen.py +0 -0
  38. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/character_blueprint.py +0 -0
  39. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/companies.py +0 -0
  40. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/developers.py +0 -0
  41. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/dicerolls.py +0 -0
  42. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/dictionary.py +0 -0
  43. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/global_admin.py +0 -0
  44. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/identity.py +0 -0
  45. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/system.py +0 -0
  46. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/user_lookup.py +0 -0
  47. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/services/users.py +0 -0
  48. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/testing/__init__.py +0 -0
  49. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/_sync/testing/_client.py +0 -0
  50. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/client.py +0 -0
  51. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/config.py +0 -0
  52. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/audit_logs.py +0 -0
  53. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/books.py +0 -0
  54. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/campaigns.py +0 -0
  55. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/chapters.py +0 -0
  56. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/character_autogen.py +0 -0
  57. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/character_blueprint.py +0 -0
  58. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/developers.py +0 -0
  59. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/diceroll.py +0 -0
  60. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/dictionary.py +0 -0
  61. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/full_sheet.py +0 -0
  62. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/global_admin.py +0 -0
  63. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/pagination.py +0 -0
  64. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/system.py +0 -0
  65. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/user_lookup.py +0 -0
  66. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/models/users.py +0 -0
  67. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/py.typed +0 -0
  68. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/registry.py +0 -0
  69. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/__init__.py +0 -0
  70. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/_audit_params.py +0 -0
  71. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/base.py +0 -0
  72. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/campaign_book_chapters.py +0 -0
  73. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/campaign_books.py +0 -0
  74. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/campaigns.py +0 -0
  75. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/character_autogen.py +0 -0
  76. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/character_blueprint.py +0 -0
  77. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/companies.py +0 -0
  78. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/developers.py +0 -0
  79. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/dicerolls.py +0 -0
  80. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/dictionary.py +0 -0
  81. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/global_admin.py +0 -0
  82. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/identity.py +0 -0
  83. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/system.py +0 -0
  84. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/user_lookup.py +0 -0
  85. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/services/users.py +0 -0
  86. {valentina_python_client-3.3.0 → valentina_python_client-3.4.0}/src/vclient/testing/_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Async Python client library for the Valentina Noir API
5
5
  Author: Nate Landau
6
6
  Author-email: Nate Landau <github@natenate.org>
@@ -15,11 +15,12 @@ License: MIT License
15
15
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
- Requires-Dist: anyio>=4.14.2,<5.0.0
19
- Requires-Dist: httpx2>=2.9.1
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Requires-Dist: anyio>=4.15.1,<5.0.0
20
+ Requires-Dist: httpx2>=2.13.1
20
21
  Requires-Dist: loguru>=0.7.3
21
22
  Requires-Dist: polyfactory>=3.3.0,<4.0.0
22
- Requires-Dist: pydantic[email]>=2.13.4,<3.0.0
23
+ Requires-Dist: pydantic[email]>=2.14.1,<3.0.0
23
24
  Requires-Dist: polyfactory>=3.3.0 ; extra == 'testing'
24
25
  Requires-Python: >=3.13
25
26
  Project-URL: Homepage, https://docs.valentina-noir.com/python-api-client/
@@ -2,19 +2,20 @@
2
2
  classifiers = [
3
3
  "Programming Language :: Python :: 3.13",
4
4
  "Programming Language :: Python :: 3.14",
5
+ "Programming Language :: Python :: 3.15",
5
6
  ]
6
7
  dependencies = [
7
- "anyio>=4.14.2,<5.0.0",
8
- "httpx2>=2.9.1",
8
+ "anyio>=4.15.1,<5.0.0",
9
+ "httpx2>=2.13.1",
9
10
  "loguru>=0.7.3",
10
11
  "polyfactory>=3.3.0, <4.0.0",
11
- "pydantic[email]>=2.13.4, <3.0.0",
12
+ "pydantic[email]>=2.14.1,<3.0.0",
12
13
  ]
13
14
  description = "Async Python client library for the Valentina Noir API"
14
15
  name = "valentina-python-client"
15
16
  readme = "README.md"
16
17
  requires-python = ">=3.13"
17
- version = "3.3.0"
18
+ version = "3.4.0"
18
19
 
19
20
  [[project.authors]]
20
21
  name = "Nate Landau"
@@ -32,7 +33,7 @@ Repository = "https://github.com/natelandau/valentina-python-client"
32
33
 
33
34
  [build-system]
34
35
  build-backend = "uv_build"
35
- requires = ["uv_build>=0.11.15,<0.13.0"]
36
+ requires = ["uv_build>=0.13.0,<0.14"]
36
37
 
37
38
  [tool.uv.build-backend]
38
39
  module-name = "vclient"
@@ -216,27 +217,28 @@ error-on-warning = true
216
217
 
217
218
  [dependency-groups]
218
219
  dev = [
219
- "commitizen>=4.17.0",
220
- "coverage>=7.15.3",
221
- "duty>=1.9.0",
220
+ "commitizen>=4.19.2",
221
+ "coverage>=7.16.2",
222
+ "duty>=1.10.1",
223
+ "gate-cache>=0.6.0",
222
224
  "httpx>=0.28.1",
223
- "prek>=0.4.12",
225
+ "prek>=0.5.5",
224
226
  "pytest-anyio>=0.0.0",
225
227
  "pytest-clarity>=1.0.1",
226
228
  "pytest-cov>=7.1.0",
227
229
  "pytest-devtools>=1.3.0",
228
230
  "pytest-httpx2>=1.0.0",
229
- "pytest-mock>=3.15.1",
231
+ "pytest-mock>=3.16.0",
230
232
  "pytest-repeat>=0.9.4",
231
233
  "pytest-sugar>=1.1.1",
232
234
  "pytest-xdist>=3.8.0",
233
235
  "pytest>=9.1.1",
234
236
  "respx>=0.23.1",
235
- "ruff>=0.16.1",
237
+ "ruff>=0.17.0",
236
238
  "shellcheck-py>=0.11.0.1",
237
- "ty>=0.0.65",
238
- "typos>=1.48.0",
239
+ "ty>=0.0.86",
240
+ "typos>=1.51.1",
239
241
  "vulture>=2.16",
240
242
  "yamllint>=1.38.0",
241
243
  ]
242
- docs = ["zensical>=0.0.52"]
244
+ docs = ["zensical>=0.0.70"]
@@ -3,20 +3,21 @@
3
3
  classifiers = [
4
4
  "Programming Language :: Python :: 3.13",
5
5
  "Programming Language :: Python :: 3.14",
6
+ "Programming Language :: Python :: 3.15",
6
7
  ]
7
8
  dependencies = [
8
- "anyio>=4.14.2,<5.0.0",
9
- "httpx2>=2.9.1",
9
+ "anyio>=4.15.1,<5.0.0",
10
+ "httpx2>=2.13.1",
10
11
  "loguru>=0.7.3",
11
12
  "polyfactory>=3.3.0, <4.0.0",
12
- "pydantic[email]>=2.13.4, <3.0.0",
13
+ "pydantic[email]>=2.14.1,<3.0.0",
13
14
  ]
14
15
  description = "Async Python client library for the Valentina Noir API"
15
16
  license = { file = "LICENSE" }
16
17
  name = "valentina-python-client"
17
18
  readme = "README.md"
18
19
  requires-python = ">=3.13"
19
- version = "3.3.0"
20
+ version = "3.4.0"
20
21
 
21
22
  [project.optional-dependencies]
22
23
  testing = ["polyfactory>=3.3.0"]
@@ -27,37 +28,38 @@
27
28
 
28
29
  [build-system]
29
30
  build-backend = "uv_build"
30
- requires = ["uv_build>=0.11.15,<0.13.0"]
31
+ requires = ["uv_build>=0.13.0,<0.14"]
31
32
 
32
33
  [tool.uv.build-backend]
33
34
  module-name = "vclient"
34
35
 
35
36
  [dependency-groups]
36
37
  dev = [
37
- "commitizen>=4.17.0",
38
- "coverage>=7.15.3",
39
- "duty>=1.9.0",
38
+ "commitizen>=4.19.2",
39
+ "coverage>=7.16.2",
40
+ "duty>=1.10.1",
41
+ "gate-cache>=0.6.0",
40
42
  "httpx>=0.28.1",
41
- "prek>=0.4.12",
43
+ "prek>=0.5.5",
42
44
  "pytest-anyio>=0.0.0",
43
45
  "pytest-clarity>=1.0.1",
44
46
  "pytest-cov>=7.1.0",
45
47
  "pytest-devtools>=1.3.0",
46
48
  "pytest-httpx2>=1.0.0",
47
- "pytest-mock>=3.15.1",
49
+ "pytest-mock>=3.16.0",
48
50
  "pytest-repeat>=0.9.4",
49
51
  "pytest-sugar>=1.1.1",
50
52
  "pytest-xdist>=3.8.0",
51
53
  "pytest>=9.1.1",
52
54
  "respx>=0.23.1",
53
- "ruff>=0.16.1",
55
+ "ruff>=0.17.0",
54
56
  "shellcheck-py>=0.11.0.1",
55
- "ty>=0.0.65",
56
- "typos>=1.48.0",
57
+ "ty>=0.0.86",
58
+ "typos>=1.51.1",
57
59
  "vulture>=2.16",
58
60
  "yamllint>=1.38.0",
59
- ]
60
- docs = ["zensical>=0.0.52"]
61
+ ]
62
+ docs = ["zensical>=0.0.70"]
61
63
 
62
64
  [tool.commitizen]
63
65
  bump_message = "bump(release): v$current_version → v$new_version"
@@ -116,4 +116,4 @@ __all__ = (
116
116
  "users_service",
117
117
  )
118
118
 
119
- __version__ = "3.3.0"
119
+ __version__ = "3.4.0"
@@ -5,15 +5,21 @@ from collections.abc import Iterator
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from vclient._sync.services.base import SyncBaseService
8
- from vclient.constants import DEFAULT_PAGE_LIMIT, TraitModifyCurrency
8
+ from vclient.constants import DEFAULT_PAGE_LIMIT, DamageType, TrackerAction, TraitModifyCurrency
9
9
  from vclient.endpoints import Endpoints
10
10
  from vclient.models import (
11
+ _TRACKER_ADAPTER,
11
12
  BulkAssignTraitResponse,
12
13
  CharacterTrait,
13
14
  CharacterTraitAdd,
15
+ CharacterTraitSpecialtyCreate,
16
+ CharacterTraitSpecialtyUpdate,
14
17
  CharacterTraitValueOptionsResponse,
15
18
  PaginatedResponse,
19
+ Tracker,
20
+ TrackerStateSet,
16
21
  TraitCreate,
22
+ _TrackerAction,
17
23
  _TraitModify,
18
24
  )
19
25
 
@@ -140,6 +146,8 @@ class SyncCharacterTraitsService(SyncBaseService):
140
146
  def delete(self, character_trait_id: str, currency: TraitModifyCurrency | None = None) -> None:
141
147
  """Delete a character trait.
142
148
 
149
+ Any specialties on the trait are removed with it and the cost of each is refunded.
150
+
143
151
  Args:
144
152
  character_trait_id: The ID of the trait to delete.
145
153
  currency: The currency to use to recoup the cost of the trait.
@@ -152,6 +160,8 @@ class SyncCharacterTraitsService(SyncBaseService):
152
160
  AuthorizationError: If you don't have access to the character.
153
161
  RequestValidationError: If the input parameters fail client-side validation.
154
162
  ValidationError: If the request data is invalid.
163
+ ConflictError: If recouping ``XP`` or ``STARTING_POINTS`` and another request
164
+ changed the trait's value first. Refetch the trait and retry.
155
165
  """
156
166
  params: dict[str, str | int] = {}
157
167
  if currency is not None:
@@ -177,6 +187,8 @@ class SyncCharacterTraitsService(SyncBaseService):
177
187
  AuthorizationError: If you don't have access to the character.
178
188
  RequestValidationError: If the input parameters fail client-side validation.
179
189
  ValidationError: If the request data is invalid.
190
+ ConflictError: If paying with ``XP`` or ``STARTING_POINTS`` and another request
191
+ changed the trait's value first. Refetch the trait and retry.
180
192
  """
181
193
  body = self._validate_request(
182
194
  CharacterTraitAdd, trait_id=trait_id, value=value, currency=currency
@@ -225,6 +237,13 @@ class SyncCharacterTraitsService(SyncBaseService):
225
237
  show_when_zero (bool), is_rollable (bool, default True),
226
238
  initial_cost (int | None), upgrade_cost (int | None),
227
239
  count_based_cost_multiplier (int | None).
240
+
241
+ Returns:
242
+ The newly created CharacterTrait object.
243
+
244
+ Raises:
245
+ ConflictError: If paying with ``XP`` or ``STARTING_POINTS`` and another request
246
+ changed the trait's value first. Refetch the trait and retry.
228
247
  """
229
248
  body = request if request is not None else self._validate_request(TraitCreate, **kwargs)
230
249
  response = self._post(
@@ -260,10 +279,20 @@ class SyncCharacterTraitsService(SyncBaseService):
260
279
  ) -> CharacterTrait:
261
280
  """Change the value of a character trait.
262
281
 
282
+ Lowering an ability to ``0`` also removes its specialties and refunds the cost of
283
+ each. The value change and the refunds are applied together or not at all.
284
+
263
285
  Args:
264
286
  character_trait_id: The ID of the trait to change the value of.
265
287
  new_value: The new value of the trait.
266
288
  currency: The currency to use for the modification.
289
+
290
+ Returns:
291
+ The updated CharacterTrait object.
292
+
293
+ Raises:
294
+ ConflictError: If another request changed the trait's value after this request
295
+ read it. Nothing is spent or refunded. Refetch the trait and retry.
267
296
  """
268
297
  body = self._validate_request(_TraitModify, target_value=new_value, currency=currency)
269
298
  response = self._put(
@@ -273,3 +302,197 @@ class SyncCharacterTraitsService(SyncBaseService):
273
302
  json=body.model_dump(exclude_none=True, exclude_unset=True, mode="json"),
274
303
  )
275
304
  return CharacterTrait.model_validate(response.json())
305
+
306
+ def add_specialty(
307
+ self, character_trait_id: str, name: str, currency: TraitModifyCurrency = "NO_COST"
308
+ ) -> CharacterTrait:
309
+ """Add a specialty to an ability trait.
310
+
311
+ Specialties are only available on abilities with a value of 1 or higher. When the
312
+ company's ``specialty_xp_cost_enabled`` setting is on, a specialty paid with ``XP``
313
+ or ``STARTING_POINTS`` costs 3 points, and ``NO_COST`` follows the company's
314
+ ``permission_free_trait_changes`` setting. When the setting is off, specialties are free.
315
+
316
+ Args:
317
+ character_trait_id: The ID of the ability trait.
318
+ name: The specialty name (3-50 characters), unique on the trait ignoring case.
319
+ currency: The currency to use to pay for the specialty.
320
+
321
+ Returns:
322
+ The updated CharacterTrait object, including its specialties.
323
+
324
+ Raises:
325
+ NotFoundError: If the trait does not exist.
326
+ AuthorizationError: If you don't have access to the character.
327
+ RequestValidationError: If the input parameters fail client-side validation.
328
+ ValidationError: If the trait is not an ability, has a value of 0, already has a
329
+ specialty with this name, the character cannot afford the cost, or an
330
+ ``NPC`` or ``STORYTELLER`` character pays with anything but ``NO_COST``.
331
+ """
332
+ body = self._validate_request(CharacterTraitSpecialtyCreate, name=name, currency=currency)
333
+ response = self._post(
334
+ self._format_endpoint(
335
+ Endpoints.CHARACTER_TRAIT_SPECIALTIES, character_trait_id=character_trait_id
336
+ ),
337
+ json=body.model_dump(exclude_none=True, mode="json"),
338
+ )
339
+ return CharacterTrait.model_validate(response.json())
340
+
341
+ def update_specialty(
342
+ self, character_trait_id: str, specialty_id: str, name: str
343
+ ) -> CharacterTrait:
344
+ """Rename a specialty on an ability trait.
345
+
346
+ Renaming never changes the cost paid or the currency used.
347
+
348
+ Args:
349
+ character_trait_id: The ID of the ability trait.
350
+ specialty_id: The ID of the specialty to rename.
351
+ name: The new specialty name (3-50 characters), unique on the trait ignoring case.
352
+
353
+ Returns:
354
+ The updated CharacterTrait object, including its specialties.
355
+
356
+ Raises:
357
+ NotFoundError: If the trait or specialty does not exist, or the specialty
358
+ belongs to a different trait.
359
+ AuthorizationError: If you don't have access to the character.
360
+ RequestValidationError: If the input parameters fail client-side validation.
361
+ ValidationError: If the trait already has a specialty with this name.
362
+ """
363
+ body = self._validate_request(CharacterTraitSpecialtyUpdate, name=name)
364
+ response = self._patch(
365
+ self._format_endpoint(
366
+ Endpoints.CHARACTER_TRAIT_SPECIALTY,
367
+ character_trait_id=character_trait_id,
368
+ specialty_id=specialty_id,
369
+ ),
370
+ json=body.model_dump(exclude_none=True, mode="json"),
371
+ )
372
+ return CharacterTrait.model_validate(response.json())
373
+
374
+ def delete_specialty(self, character_trait_id: str, specialty_id: str) -> None:
375
+ """Remove a specialty from an ability trait.
376
+
377
+ The cost paid is refunded in the currency used to buy it. An ``XP`` refund follows
378
+ the company's ``permission_recoup_xp`` setting; starting points are always refunded.
379
+
380
+ Args:
381
+ character_trait_id: The ID of the ability trait.
382
+ specialty_id: The ID of the specialty to remove.
383
+
384
+ Returns:
385
+ None
386
+
387
+ Raises:
388
+ NotFoundError: If the trait or specialty does not exist, or the specialty
389
+ belongs to a different trait.
390
+ AuthorizationError: If you don't have access to the character.
391
+ """
392
+ self._delete(
393
+ self._format_endpoint(
394
+ Endpoints.CHARACTER_TRAIT_SPECIALTY,
395
+ character_trait_id=character_trait_id,
396
+ specialty_id=specialty_id,
397
+ )
398
+ )
399
+
400
+ def get_tracker(self, character_trait_id: str) -> Tracker:
401
+ """Get the in-play tracker state of a character trait.
402
+
403
+ The same object appears as ``CharacterTrait.tracker``. Use this to refresh one
404
+ tracker without fetching the whole trait.
405
+
406
+ Args:
407
+ character_trait_id: The ID of the character trait.
408
+
409
+ Returns:
410
+ A CounterTracker, DamageTracker, or StainsTracker. Switch on ``type``.
411
+
412
+ Raises:
413
+ NotFoundError: If the trait has no tracker for the character's game version.
414
+ AuthorizationError: If you don't have access to the character.
415
+ """
416
+ response = self._get(
417
+ self._format_endpoint(
418
+ Endpoints.CHARACTER_TRAIT_TRACKER, character_trait_id=character_trait_id
419
+ )
420
+ )
421
+ return _TRACKER_ADAPTER.validate_python(response.json())
422
+
423
+ def set_tracker(
424
+ self, character_trait_id: str, request: TrackerStateSet | None = None, **kwargs
425
+ ) -> Tracker:
426
+ """Replace the full state of a trait tracker, such as to correct a mistake.
427
+
428
+ Send only the fields for the tracker's type: ``current`` for counters (required),
429
+ ``superficial`` and ``aggravated`` for damage tracks, ``stains`` for stains. Omitted
430
+ fields reset to 0, so an empty request clears a damage or stains tracker. Use
431
+ ``apply_tracker_action`` for in-play changes instead.
432
+
433
+ Args:
434
+ character_trait_id: The ID of the character trait.
435
+ request: A TrackerStateSet model, OR pass fields as keyword arguments.
436
+ **kwargs: Fields for TrackerStateSet if request is not provided.
437
+ Accepts: current, superficial, aggravated, stains (int >= 0).
438
+
439
+ Returns:
440
+ The updated tracker.
441
+
442
+ Raises:
443
+ NotFoundError: If the trait has no tracker for the character's game version.
444
+ AuthorizationError: If you are not the character's player or a storyteller.
445
+ RequestValidationError: If a value is negative.
446
+ ValidationError: If a field does not fit the tracker type, a counter has no
447
+ ``current``, or a value exceeds the tracker size.
448
+ """
449
+ body = request if request is not None else self._validate_request(TrackerStateSet, **kwargs)
450
+ response = self._put(
451
+ self._format_endpoint(
452
+ Endpoints.CHARACTER_TRAIT_TRACKER, character_trait_id=character_trait_id
453
+ ),
454
+ json=body.model_dump(exclude_none=True, mode="json"),
455
+ )
456
+ return _TRACKER_ADAPTER.validate_python(response.json())
457
+
458
+ def apply_tracker_action(
459
+ self,
460
+ character_trait_id: str,
461
+ action: TrackerAction,
462
+ *,
463
+ amount: int = 1,
464
+ damage_type: DamageType | None = None,
465
+ ) -> Tracker:
466
+ """Apply one in-play action to a trait tracker, such as spending Willpower.
467
+
468
+ The server applies the game rules for the character's game version. Which actions
469
+ and damage types fit each tracker is listed in ``characters.TrackerRules`` from
470
+ ``options.get_options()``. A STAINS response reports overflow into Willpower in
471
+ ``willpower_damage_applied``.
472
+
473
+ Args:
474
+ character_trait_id: The ID of the character trait.
475
+ action: The action to apply.
476
+ amount: How many points to apply. Must be at least 1.
477
+ damage_type: The damage type for ``damage`` and ``heal`` on damage tracks.
478
+
479
+ Returns:
480
+ The updated tracker.
481
+
482
+ Raises:
483
+ NotFoundError: If the trait has no tracker for the character's game version.
484
+ AuthorizationError: If you are not the character's player or a storyteller.
485
+ RequestValidationError: If ``amount`` is less than 1.
486
+ ValidationError: If the action or damage type does not fit the tracker, a
487
+ damage type is missing or not allowed, or a spend exceeds the current value.
488
+ """
489
+ body = self._validate_request(
490
+ _TrackerAction, action=action, amount=amount, damage_type=damage_type
491
+ )
492
+ response = self._post(
493
+ self._format_endpoint(
494
+ Endpoints.CHARACTER_TRAIT_TRACKER_ACTIONS, character_trait_id=character_trait_id
495
+ ),
496
+ json=body.model_dump(exclude_none=True, mode="json"),
497
+ )
498
+ return _TRACKER_ADAPTER.validate_python(response.json())
@@ -12,6 +12,8 @@ from vclient.constants import (
12
12
  CharacterInclude,
13
13
  CharacterStatus,
14
14
  CharacterType,
15
+ DamageType,
16
+ TrackerAction,
15
17
  )
16
18
  from vclient.endpoints import Endpoints
17
19
  from vclient.models import (
@@ -22,6 +24,8 @@ from vclient.models import (
22
24
  CharacterFullSheet,
23
25
  CharacterUpdate,
24
26
  FullSheetTraitCategory,
27
+ Health,
28
+ HealthStateSet,
25
29
  InventoryItem,
26
30
  InventoryItemCreate,
27
31
  InventoryItemUpdate,
@@ -30,6 +34,7 @@ from vclient.models import (
30
34
  NoteUpdate,
31
35
  PaginatedResponse,
32
36
  RollStatistics,
37
+ _TrackerAction,
33
38
  )
34
39
 
35
40
  if TYPE_CHECKING:
@@ -873,3 +878,94 @@ class SyncCharactersService(SyncBaseService):
873
878
  Endpoints.CHARACTER_INVENTORY_ITEM, character_id=character_id, item_id=item_id
874
879
  )
875
880
  )
881
+
882
+ def get_health(self, character_id: str) -> Health:
883
+ """Get a character's health track.
884
+
885
+ The same object appears as ``Character.health``. Re-fetch it after changing V5
886
+ Stamina, which sets the track size.
887
+
888
+ Args:
889
+ character_id: The ID of the character.
890
+
891
+ Returns:
892
+ The character's Health.
893
+
894
+ Raises:
895
+ NotFoundError: If the character does not exist or has no health track.
896
+ AuthorizationError: If you don't have access to the character.
897
+ """
898
+ response = self._get(
899
+ self._format_endpoint(Endpoints.CHARACTER_HEALTH, character_id=character_id)
900
+ )
901
+ return Health.model_validate(response.json())
902
+
903
+ def set_health(
904
+ self, character_id: str, request: HealthStateSet | None = None, **kwargs
905
+ ) -> Health:
906
+ """Replace a character's full health state, such as to correct a mistake.
907
+
908
+ Every field is sent, so omitted fields reset to 0. V4 uses ``bashing``,
909
+ ``lethal``, and ``aggravated``; V5 uses ``superficial`` and ``aggravated``.
910
+ ``extra_levels`` applies to both. Use ``apply_health_action`` for in-play changes.
911
+
912
+ Args:
913
+ character_id: The ID of the character.
914
+ request: A HealthStateSet model, OR pass fields as keyword arguments.
915
+ **kwargs: Fields for HealthStateSet if request is not provided.
916
+ Accepts: extra_levels, superficial, aggravated, bashing, lethal (int >= 0).
917
+
918
+ Returns:
919
+ The updated Health.
920
+
921
+ Raises:
922
+ NotFoundError: If the character does not exist or has no health track.
923
+ AuthorizationError: If you are not the character's player or a storyteller.
924
+ RequestValidationError: If a value is negative.
925
+ ValidationError: If a nonzero field is not used by the game version, or the
926
+ marks exceed the track size.
927
+ """
928
+ body = request if request is not None else self._validate_request(HealthStateSet, **kwargs)
929
+ response = self._put(
930
+ self._format_endpoint(Endpoints.CHARACTER_HEALTH, character_id=character_id),
931
+ json=body.model_dump(mode="json"),
932
+ )
933
+ return Health.model_validate(response.json())
934
+
935
+ def apply_health_action(
936
+ self,
937
+ character_id: str,
938
+ action: TrackerAction,
939
+ *,
940
+ amount: int = 1,
941
+ damage_type: DamageType | None = None,
942
+ ) -> Health:
943
+ """Apply one in-play action to a character's health, such as taking damage.
944
+
945
+ Health accepts ``damage``, ``heal``, and ``reset``. V4 damage types are
946
+ ``BASHING``, ``LETHAL``, and ``AGGRAVATED``; V5 damage types are ``SUPERFICIAL``
947
+ and ``AGGRAVATED``. The server applies overflow and upgrade rules.
948
+
949
+ Args:
950
+ character_id: The ID of the character.
951
+ action: The action to apply.
952
+ amount: How many points to apply. Must be at least 1.
953
+ damage_type: The damage type for ``damage`` and ``heal``.
954
+
955
+ Returns:
956
+ The updated Health.
957
+
958
+ Raises:
959
+ NotFoundError: If the character does not exist or has no health track.
960
+ AuthorizationError: If you are not the character's player or a storyteller.
961
+ RequestValidationError: If ``amount`` is less than 1.
962
+ ValidationError: If the action or damage type does not fit the game version.
963
+ """
964
+ body = self._validate_request(
965
+ _TrackerAction, action=action, amount=amount, damage_type=damage_type
966
+ )
967
+ response = self._post(
968
+ self._format_endpoint(Endpoints.CHARACTER_HEALTH_ACTIONS, character_id=character_id),
969
+ json=body.model_dump(exclude_none=True, mode="json"),
970
+ )
971
+ return Health.model_validate(response.json())
@@ -2,7 +2,7 @@
2
2
  """Options and enumerations service."""
3
3
 
4
4
  import json
5
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from vclient._sync.services.base import SyncBaseService
8
8
  from vclient.endpoints import Endpoints
@@ -32,9 +32,14 @@ class SyncOptionsService(SyncBaseService):
32
32
  """Format an endpoint with the scoped company_id plus any extra params."""
33
33
  return endpoint.format(company_id=self._company_id, **kwargs)
34
34
 
35
- def get_options(self) -> dict[str, dict[str, list[str] | dict[str, str]]]:
35
+ def get_options(self) -> dict[str, dict[str, Any]]:
36
36
  """Retrieve all options and enumerations for the api.
37
37
 
38
+ Most values are lists of enum values. ``characters.TrackerRules`` is a nested map
39
+ ``{game_version: {tracker_key: {"actions": [...], "damage_types": [...]}}}`` where
40
+ ``tracker_key`` is a tracker type or ``HEALTH``. Use it to choose which tracker
41
+ actions and damage types to offer instead of hardcoding them.
42
+
38
43
  Returns:
39
44
  A dictionary of options and enumerations for the api.
40
45
  """
@@ -50,8 +50,10 @@ AuditEntityType = Literal[
50
50
  "CAMPAIGN",
51
51
  "CHAPTER",
52
52
  "CHARACTER",
53
+ "CHARACTER_HEALTH",
53
54
  "CHARACTER_INVENTORY",
54
55
  "CHARACTER_TRAIT",
56
+ "CHARACTER_TRAIT_SPECIALTY",
55
57
  "CHARGEN_SESSION",
56
58
  "COMPANY",
57
59
  "DEVELOPER",
@@ -102,3 +104,6 @@ UserRole = Literal["ADMIN", "STORYTELLER", "PLAYER", "UNAPPROVED", "DEACTIVATED"
102
104
  WerewolfRenown = Literal["HONOR", "GLORY", "WISDOM"]
103
105
  BlueprintTraitOrderBy = Literal["NAME", "SHEET"]
104
106
  TraitModifyCurrency = Literal["XP", "STARTING_POINTS", "NO_COST"]
107
+ TrackerType = Literal["COUNTER", "DAMAGE", "STAINS"]
108
+ TrackerAction = Literal["spend", "gain", "damage", "heal", "add_stain", "remove_stain", "reset"]
109
+ DamageType = Literal["SUPERFICIAL", "AGGRAVATED", "BASHING", "LETHAL"]
@@ -127,6 +127,12 @@ class Endpoints:
127
127
  CHARACTER_TRAIT = f"{CHARACTER_TRAITS}/{{character_trait_id}}"
128
128
  CHARACTER_TRAIT_VALUE_OPTIONS = f"{CHARACTER_TRAIT}/value-options"
129
129
  CHARACTER_TRAIT_VALUE = f"{CHARACTER_TRAIT}/value"
130
+ CHARACTER_TRAIT_SPECIALTIES = f"{CHARACTER_TRAIT}/specialties"
131
+ CHARACTER_TRAIT_SPECIALTY = f"{CHARACTER_TRAIT_SPECIALTIES}/{{specialty_id}}"
132
+ CHARACTER_TRAIT_TRACKER = f"{CHARACTER_TRAIT}/tracker"
133
+ CHARACTER_TRAIT_TRACKER_ACTIONS = f"{CHARACTER_TRAIT_TRACKER}/actions"
134
+ CHARACTER_HEALTH = f"{CHARACTER}/health"
135
+ CHARACTER_HEALTH_ACTIONS = f"{CHARACTER_HEALTH}/actions"
130
136
 
131
137
  # Character Blueprint endpoints
132
138
  BLUEPRINT_BASE = f"{COMPANY}/characterblueprint"
@@ -131,7 +131,9 @@ class NotFoundError(APIError):
131
131
  class ConflictError(APIError):
132
132
  """Raised when there is a conflict (409 Conflict).
133
133
 
134
- Common cause is reusing an idempotency key with a different request body.
134
+ Common causes are reusing an idempotency key with a different request body, or
135
+ another request changing a character trait's value first. For the latter, refetch
136
+ the trait and retry.
135
137
  """
136
138
 
137
139