kirby-combat 0.3.28__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 (156) hide show
  1. kirby_combat-0.3.28/LICENSE +21 -0
  2. kirby_combat-0.3.28/PKG-INFO +95 -0
  3. kirby_combat-0.3.28/README.md +76 -0
  4. kirby_combat-0.3.28/kirby_combat/__init__.py +2 -0
  5. kirby_combat-0.3.28/kirby_combat/actions/__init__.py +36 -0
  6. kirby_combat-0.3.28/kirby_combat/actions/area_of_effect.py +396 -0
  7. kirby_combat-0.3.28/kirby_combat/actions/autofire.py +129 -0
  8. kirby_combat-0.3.28/kirby_combat/actions/base.py +221 -0
  9. kirby_combat-0.3.28/kirby_combat/actions/brace.py +102 -0
  10. kirby_combat-0.3.28/kirby_combat/actions/cv_parser.py +67 -0
  11. kirby_combat-0.3.28/kirby_combat/actions/dive_for_cover.py +156 -0
  12. kirby_combat-0.3.28/kirby_combat/actions/entangle.py +207 -0
  13. kirby_combat-0.3.28/kirby_combat/actions/flash.py +199 -0
  14. kirby_combat-0.3.28/kirby_combat/actions/grab.py +231 -0
  15. kirby_combat-0.3.28/kirby_combat/actions/haymaker.py +81 -0
  16. kirby_combat-0.3.28/kirby_combat/actions/held_action.py +300 -0
  17. kirby_combat-0.3.28/kirby_combat/actions/killing.py +6 -0
  18. kirby_combat-0.3.28/kirby_combat/actions/martial_arts.py +255 -0
  19. kirby_combat-0.3.28/kirby_combat/actions/move_by.py +76 -0
  20. kirby_combat-0.3.28/kirby_combat/actions/move_through.py +71 -0
  21. kirby_combat-0.3.28/kirby_combat/actions/movement/__init__.py +18 -0
  22. kirby_combat-0.3.28/kirby_combat/actions/movement/base.py +155 -0
  23. kirby_combat-0.3.28/kirby_combat/actions/movement/flight.py +30 -0
  24. kirby_combat-0.3.28/kirby_combat/actions/movement/knockback_movement.py +189 -0
  25. kirby_combat-0.3.28/kirby_combat/actions/movement/leaping.py +30 -0
  26. kirby_combat-0.3.28/kirby_combat/actions/movement/running.py +26 -0
  27. kirby_combat-0.3.28/kirby_combat/actions/movement/swimming.py +29 -0
  28. kirby_combat-0.3.28/kirby_combat/actions/movement/teleportation.py +31 -0
  29. kirby_combat-0.3.28/kirby_combat/actions/movement/tunneling.py +31 -0
  30. kirby_combat-0.3.28/kirby_combat/actions/multiple_attack.py +41 -0
  31. kirby_combat-0.3.28/kirby_combat/actions/pulling_punch.py +79 -0
  32. kirby_combat-0.3.28/kirby_combat/actions/ranged.py +6 -0
  33. kirby_combat-0.3.28/kirby_combat/actions/rapid_fire.py +31 -0
  34. kirby_combat-0.3.28/kirby_combat/actions/reactive/__init__.py +13 -0
  35. kirby_combat-0.3.28/kirby_combat/actions/reactive/abort.py +45 -0
  36. kirby_combat-0.3.28/kirby_combat/actions/reactive/block.py +63 -0
  37. kirby_combat-0.3.28/kirby_combat/actions/reactive/dodge.py +37 -0
  38. kirby_combat-0.3.28/kirby_combat/actions/set_action.py +84 -0
  39. kirby_combat-0.3.28/kirby_combat/actions/strike.py +6 -0
  40. kirby_combat-0.3.28/kirby_combat/actions/sweep.py +21 -0
  41. kirby_combat-0.3.28/kirby_combat/actions/throw.py +123 -0
  42. kirby_combat-0.3.28/kirby_combat/actions/trigger.py +158 -0
  43. kirby_combat-0.3.28/kirby_combat/breakables/__init__.py +14 -0
  44. kirby_combat-0.3.28/kirby_combat/breakables/object_combatant.py +82 -0
  45. kirby_combat-0.3.28/kirby_combat/breakables/structure.py +120 -0
  46. kirby_combat-0.3.28/kirby_combat/dice/__init__.py +5 -0
  47. kirby_combat-0.3.28/kirby_combat/dice/fake.py +42 -0
  48. kirby_combat-0.3.28/kirby_combat/dice/roller.py +49 -0
  49. kirby_combat-0.3.28/kirby_combat/gm/__init__.py +24 -0
  50. kirby_combat-0.3.28/kirby_combat/gm/gm_attack.py +64 -0
  51. kirby_combat-0.3.28/kirby_combat/gm/overrides.py +202 -0
  52. kirby_combat-0.3.28/kirby_combat/gm/spawn_despawn.py +78 -0
  53. kirby_combat-0.3.28/kirby_combat/hero_view.py +1619 -0
  54. kirby_combat-0.3.28/kirby_combat/masscombat/__init__.py +14 -0
  55. kirby_combat-0.3.28/kirby_combat/masscombat/resolution.py +76 -0
  56. kirby_combat-0.3.28/kirby_combat/masscombat/unit.py +76 -0
  57. kirby_combat-0.3.28/kirby_combat/mental/__init__.py +36 -0
  58. kirby_combat-0.3.28/kirby_combat/mental/mental_blast.py +53 -0
  59. kirby_combat-0.3.28/kirby_combat/mental/mental_combat.py +63 -0
  60. kirby_combat-0.3.28/kirby_combat/mental/mental_entangle.py +107 -0
  61. kirby_combat-0.3.28/kirby_combat/mental/mental_illusion.py +98 -0
  62. kirby_combat-0.3.28/kirby_combat/mental/mind_control.py +73 -0
  63. kirby_combat-0.3.28/kirby_combat/mental/telepathy.py +55 -0
  64. kirby_combat-0.3.28/kirby_combat/models.py +323 -0
  65. kirby_combat-0.3.28/kirby_combat/perception.py +585 -0
  66. kirby_combat-0.3.28/kirby_combat/pre_attacks/__init__.py +10 -0
  67. kirby_combat-0.3.28/kirby_combat/pre_attacks/presence.py +85 -0
  68. kirby_combat-0.3.28/kirby_combat/resolution/__init__.py +1 -0
  69. kirby_combat-0.3.28/kirby_combat/resolution/adjustments.py +78 -0
  70. kirby_combat-0.3.28/kirby_combat/resolution/damage.py +250 -0
  71. kirby_combat-0.3.28/kirby_combat/resolution/defense.py +311 -0
  72. kirby_combat-0.3.28/kirby_combat/resolution/knockback.py +171 -0
  73. kirby_combat-0.3.28/kirby_combat/resolution/line_of_sight.py +107 -0
  74. kirby_combat-0.3.28/kirby_combat/resolution/object_damage.py +77 -0
  75. kirby_combat-0.3.28/kirby_combat/resolution/recovery.py +106 -0
  76. kirby_combat-0.3.28/kirby_combat/resolution/status.py +46 -0
  77. kirby_combat-0.3.28/kirby_combat/resolution/to_hit.py +159 -0
  78. kirby_combat-0.3.28/kirby_combat/scene/__init__.py +20 -0
  79. kirby_combat-0.3.28/kirby_combat/scene/construct.py +154 -0
  80. kirby_combat-0.3.28/kirby_combat/scene/cover.py +124 -0
  81. kirby_combat-0.3.28/kirby_combat/scene/effects.py +74 -0
  82. kirby_combat-0.3.28/kirby_combat/scene/falling.py +94 -0
  83. kirby_combat-0.3.28/kirby_combat/scene/geometry.py +215 -0
  84. kirby_combat-0.3.28/kirby_combat/scene/hazards.py +115 -0
  85. kirby_combat-0.3.28/kirby_combat/scene/movement_legality.py +424 -0
  86. kirby_combat-0.3.28/kirby_combat/scene/scene.py +198 -0
  87. kirby_combat-0.3.28/kirby_combat/scene/visibility.py +279 -0
  88. kirby_combat-0.3.28/kirby_combat/serialization/__init__.py +5 -0
  89. kirby_combat-0.3.28/kirby_combat/serialization/from_dict.py +222 -0
  90. kirby_combat-0.3.28/kirby_combat/serialization/to_dict.py +82 -0
  91. kirby_combat-0.3.28/kirby_combat/session/__init__.py +39 -0
  92. kirby_combat-0.3.28/kirby_combat/session/apply.py +77 -0
  93. kirby_combat-0.3.28/kirby_combat/session/combat_session.py +112 -0
  94. kirby_combat-0.3.28/kirby_combat/session/effects.py +179 -0
  95. kirby_combat-0.3.28/kirby_combat/session/events.py +259 -0
  96. kirby_combat-0.3.28/kirby_combat/session/rewind.py +33 -0
  97. kirby_combat-0.3.28/kirby_combat/session/timeline.py +72 -0
  98. kirby_combat-0.3.28/kirby_combat/tables.py +281 -0
  99. kirby_combat-0.3.28/kirby_combat/template.py +94 -0
  100. kirby_combat-0.3.28/kirby_combat/vehicles/__init__.py +26 -0
  101. kirby_combat-0.3.28/kirby_combat/vehicles/controls.py +91 -0
  102. kirby_combat-0.3.28/kirby_combat/vehicles/passenger.py +84 -0
  103. kirby_combat-0.3.28/kirby_combat/vehicles/ramming.py +72 -0
  104. kirby_combat-0.3.28/kirby_combat/vehicles/vehicle.py +97 -0
  105. kirby_combat-0.3.28/kirby_combat.egg-info/PKG-INFO +95 -0
  106. kirby_combat-0.3.28/kirby_combat.egg-info/SOURCES.txt +154 -0
  107. kirby_combat-0.3.28/kirby_combat.egg-info/dependency_links.txt +1 -0
  108. kirby_combat-0.3.28/kirby_combat.egg-info/requires.txt +8 -0
  109. kirby_combat-0.3.28/kirby_combat.egg-info/top_level.txt +1 -0
  110. kirby_combat-0.3.28/pyproject.toml +34 -0
  111. kirby_combat-0.3.28/setup.cfg +4 -0
  112. kirby_combat-0.3.28/tests/test_actions.py +98 -0
  113. kirby_combat-0.3.28/tests/test_adjustments.py +86 -0
  114. kirby_combat-0.3.28/tests/test_aoe.py +165 -0
  115. kirby_combat-0.3.28/tests/test_aoe_constructs.py +60 -0
  116. kirby_combat-0.3.28/tests/test_aoe_scene_integration.py +195 -0
  117. kirby_combat-0.3.28/tests/test_attack_los.py +101 -0
  118. kirby_combat-0.3.28/tests/test_attack_power_avad.py +30 -0
  119. kirby_combat-0.3.28/tests/test_attack_view_multipower.py +249 -0
  120. kirby_combat-0.3.28/tests/test_avad_resolution.py +172 -0
  121. kirby_combat-0.3.28/tests/test_cv_parser.py +47 -0
  122. kirby_combat-0.3.28/tests/test_damage.py +274 -0
  123. kirby_combat-0.3.28/tests/test_damage_reduction_negation.py +286 -0
  124. kirby_combat-0.3.28/tests/test_defense.py +185 -0
  125. kirby_combat-0.3.28/tests/test_dice.py +68 -0
  126. kirby_combat-0.3.28/tests/test_entangle.py +232 -0
  127. kirby_combat-0.3.28/tests/test_flash.py +222 -0
  128. kirby_combat-0.3.28/tests/test_framework_view.py +32 -0
  129. kirby_combat-0.3.28/tests/test_grab_throw.py +229 -0
  130. kirby_combat-0.3.28/tests/test_held_release.py +144 -0
  131. kirby_combat-0.3.28/tests/test_hero_capabilities.py +20 -0
  132. kirby_combat-0.3.28/tests/test_hero_combatant_skeleton.py +388 -0
  133. kirby_combat-0.3.28/tests/test_knockback.py +222 -0
  134. kirby_combat-0.3.28/tests/test_maneuver_view.py +101 -0
  135. kirby_combat-0.3.28/tests/test_martial_arts.py +184 -0
  136. kirby_combat-0.3.28/tests/test_martial_maneuvers_table.py +78 -0
  137. kirby_combat-0.3.28/tests/test_move_by_through.py +139 -0
  138. kirby_combat-0.3.28/tests/test_movement_base.py +163 -0
  139. kirby_combat-0.3.28/tests/test_movement_powers.py +122 -0
  140. kirby_combat-0.3.28/tests/test_movement_reach.py +420 -0
  141. kirby_combat-0.3.28/tests/test_movement_view.py +348 -0
  142. kirby_combat-0.3.28/tests/test_multi_attack.py +189 -0
  143. kirby_combat-0.3.28/tests/test_naked_resistant.py +196 -0
  144. kirby_combat-0.3.28/tests/test_object_identity.py +146 -0
  145. kirby_combat-0.3.28/tests/test_object_throw.py +35 -0
  146. kirby_combat-0.3.28/tests/test_perception.py +932 -0
  147. kirby_combat-0.3.28/tests/test_reach.py +80 -0
  148. kirby_combat-0.3.28/tests/test_reactive.py +134 -0
  149. kirby_combat-0.3.28/tests/test_recovery.py +168 -0
  150. kirby_combat-0.3.28/tests/test_status.py +82 -0
  151. kirby_combat-0.3.28/tests/test_surface_blocks_los.py +122 -0
  152. kirby_combat-0.3.28/tests/test_tables.py +177 -0
  153. kirby_combat-0.3.28/tests/test_tactical_modifiers.py +328 -0
  154. kirby_combat-0.3.28/tests/test_to_hit.py +382 -0
  155. kirby_combat-0.3.28/tests/test_trigger.py +180 -0
  156. kirby_combat-0.3.28/tests/test_variable_slot_scaling.py +37 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PeterB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: kirby-combat
3
+ Version: 0.3.28
4
+ Summary: HERO System 6E combat engine for Kirby VTT platform
5
+ Author: PeterB
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/pdbethke/kirby-combat
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
14
+ Requires-Dist: pytest-benchmark>=4.0; extra == "dev"
15
+ Requires-Dist: hypothesis>=6.100; extra == "dev"
16
+ Requires-Dist: ruff>=0.6; extra == "dev"
17
+ Requires-Dist: mypy>=1.10; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # kirby-combat
21
+
22
+ Pure-Python combat engine for the HERO System 6th Edition, designed as the
23
+ authoritative back-end for [Kirby](https://kirby.productbinder.io) — the
24
+ HERO System VTT in the Kirby product line.
25
+
26
+ Zero runtime dependencies (stdlib only). Strict TDD. RAW-aligned against
27
+ the official 6E2 / 6E1 books, with per-rule citations on every behavioral
28
+ commit.
29
+
30
+ ## Status
31
+
32
+ - **Phase 1** (v0.1.0): Attack pipeline (to-hit / damage / defense /
33
+ knockback / status). 107 tests.
34
+ - **Phase 2 Plan 1** (v0.2.0): Engine foundation. CombatSession + event
35
+ log + rewind, reactive defenses, tactical modifiers, movement, AoE,
36
+ Scene data model, falling, cover, line-of-sight, hazards, recovery,
37
+ adjustments, entangle, flash, martial arts, triggers, held actions.
38
+ 450 tests passing. 96% coverage on `session/`, `scene/`, `resolution/`.
39
+ - **Phase 2 Plan 2** (v0.3.0): Engine advanced. Mental combat pipeline
40
+ (Mind Control / Telepathy / Mental Illusion / Mental Blast / Mental
41
+ Entangle), vehicles (HDC-shaped Combatant subtype with passengers,
42
+ ramming, driving rolls), mass combat (Unit aggregation + morale
43
+ cycling), breakables (objects + structure cascade), Presence attacks,
44
+ GM tooling engine layer (Tier 1/2/3 overrides, GM-on-behalf-of
45
+ attacks, spawn/despawn), serialization (to_dict / from_dict with
46
+ round-trip parity). 569 tests, 96% coverage.
47
+
48
+ ### engine-advanced subsystems
49
+
50
+ | Module | Purpose |
51
+ |---|---|
52
+ | `kirby_combat/mental/` | OMCV/DMCV pipeline + the five mental powers |
53
+ | `kirby_combat/vehicles/` | Vehicle (Combatant subtype), passengers, ramming, controls |
54
+ | `kirby_combat/masscombat/` | Unit (pack-of-N) and aggregate resolution |
55
+ | `kirby_combat/breakables/` | ObjectCombatant + structure integrity cascade |
56
+ | `kirby_combat/pre_attacks/` | Presence attacks with effects ladder |
57
+ | `kirby_combat/gm/` | Tier 1/2/3 GMOverride helpers + GM attack + spawn/despawn |
58
+ | `kirby_combat/serialization/` | `to_dict` / `from_dict` for full session round-trip |
59
+
60
+ ## Usage
61
+
62
+ ```python
63
+ from kirby_combat.session import CombatSession
64
+ from kirby_combat.template import CombatTemplate
65
+ from kirby_combat.dice import RandomRoller
66
+
67
+ session = CombatSession.create(
68
+ id="encounter-1",
69
+ combatants=[hero, villain],
70
+ scene=warehouse_scene,
71
+ template=CombatTemplate.default_6e_superheroic(),
72
+ dice_roller=RandomRoller(),
73
+ ).start()
74
+ ```
75
+
76
+ See `examples/` for runnable demos.
77
+
78
+ ## RAW alignment
79
+
80
+ Every game-mechanical commit cites a specific page or section in 6E1 / 6E2
81
+ (or in Dorman's MIT-licensed `dmdorman/hero6e-foundryvtt` reference port,
82
+ where Dorman's behavior is the source of truth). The Codex retrieval system
83
+ backs this — values are verified against the corpus rather than memory.
84
+
85
+ ## Tests
86
+
87
+ ```bash
88
+ .venv/bin/pytest tests/ -q
89
+ .venv/bin/pytest tests/ --cov=kirby_combat --cov-report=term-missing
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT. The combat-mechanics derivation is independent NDA-clean work — no
95
+ proprietary HERO Designer source enters this repo. See `LICENSE`.
@@ -0,0 +1,76 @@
1
+ # kirby-combat
2
+
3
+ Pure-Python combat engine for the HERO System 6th Edition, designed as the
4
+ authoritative back-end for [Kirby](https://kirby.productbinder.io) — the
5
+ HERO System VTT in the Kirby product line.
6
+
7
+ Zero runtime dependencies (stdlib only). Strict TDD. RAW-aligned against
8
+ the official 6E2 / 6E1 books, with per-rule citations on every behavioral
9
+ commit.
10
+
11
+ ## Status
12
+
13
+ - **Phase 1** (v0.1.0): Attack pipeline (to-hit / damage / defense /
14
+ knockback / status). 107 tests.
15
+ - **Phase 2 Plan 1** (v0.2.0): Engine foundation. CombatSession + event
16
+ log + rewind, reactive defenses, tactical modifiers, movement, AoE,
17
+ Scene data model, falling, cover, line-of-sight, hazards, recovery,
18
+ adjustments, entangle, flash, martial arts, triggers, held actions.
19
+ 450 tests passing. 96% coverage on `session/`, `scene/`, `resolution/`.
20
+ - **Phase 2 Plan 2** (v0.3.0): Engine advanced. Mental combat pipeline
21
+ (Mind Control / Telepathy / Mental Illusion / Mental Blast / Mental
22
+ Entangle), vehicles (HDC-shaped Combatant subtype with passengers,
23
+ ramming, driving rolls), mass combat (Unit aggregation + morale
24
+ cycling), breakables (objects + structure cascade), Presence attacks,
25
+ GM tooling engine layer (Tier 1/2/3 overrides, GM-on-behalf-of
26
+ attacks, spawn/despawn), serialization (to_dict / from_dict with
27
+ round-trip parity). 569 tests, 96% coverage.
28
+
29
+ ### engine-advanced subsystems
30
+
31
+ | Module | Purpose |
32
+ |---|---|
33
+ | `kirby_combat/mental/` | OMCV/DMCV pipeline + the five mental powers |
34
+ | `kirby_combat/vehicles/` | Vehicle (Combatant subtype), passengers, ramming, controls |
35
+ | `kirby_combat/masscombat/` | Unit (pack-of-N) and aggregate resolution |
36
+ | `kirby_combat/breakables/` | ObjectCombatant + structure integrity cascade |
37
+ | `kirby_combat/pre_attacks/` | Presence attacks with effects ladder |
38
+ | `kirby_combat/gm/` | Tier 1/2/3 GMOverride helpers + GM attack + spawn/despawn |
39
+ | `kirby_combat/serialization/` | `to_dict` / `from_dict` for full session round-trip |
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from kirby_combat.session import CombatSession
45
+ from kirby_combat.template import CombatTemplate
46
+ from kirby_combat.dice import RandomRoller
47
+
48
+ session = CombatSession.create(
49
+ id="encounter-1",
50
+ combatants=[hero, villain],
51
+ scene=warehouse_scene,
52
+ template=CombatTemplate.default_6e_superheroic(),
53
+ dice_roller=RandomRoller(),
54
+ ).start()
55
+ ```
56
+
57
+ See `examples/` for runnable demos.
58
+
59
+ ## RAW alignment
60
+
61
+ Every game-mechanical commit cites a specific page or section in 6E1 / 6E2
62
+ (or in Dorman's MIT-licensed `dmdorman/hero6e-foundryvtt` reference port,
63
+ where Dorman's behavior is the source of truth). The Codex retrieval system
64
+ backs this — values are verified against the corpus rather than memory.
65
+
66
+ ## Tests
67
+
68
+ ```bash
69
+ .venv/bin/pytest tests/ -q
70
+ .venv/bin/pytest tests/ --cov=kirby_combat --cov-report=term-missing
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT. The combat-mechanics derivation is independent NDA-clean work — no
76
+ proprietary HERO Designer source enters this repo. See `LICENSE`.
@@ -0,0 +1,2 @@
1
+ """kirby-combat: HERO System 6E combat engine."""
2
+ __version__ = "0.3.0"
@@ -0,0 +1,36 @@
1
+ """HERO System 6E action classes and attack resolution."""
2
+ from __future__ import annotations
3
+
4
+ from kirby_combat.actions.killing import KillingAttackAction
5
+ from kirby_combat.actions.ranged import RangedAttackAction
6
+ from kirby_combat.actions.strike import StrikeAction
7
+ from kirby_combat.models import AttackInput, AttackResult
8
+ from kirby_combat.template import CombatTemplate
9
+
10
+
11
+ def resolve_attack(attack: AttackInput, template: CombatTemplate) -> AttackResult:
12
+ """Resolve a single attack, picking the right action class automatically.
13
+
14
+ Routing logic:
15
+ - ``damage_type == "killing"`` -> KillingAttackAction
16
+ - ``range_m is not None`` -> RangedAttackAction
17
+ - otherwise -> StrikeAction
18
+ """
19
+ power = attack.power
20
+
21
+ if power.damage_type == "killing":
22
+ action = KillingAttackAction()
23
+ elif power.range_m is not None:
24
+ action = RangedAttackAction()
25
+ else:
26
+ action = StrikeAction()
27
+
28
+ return action.resolve(attack, template)
29
+
30
+
31
+ __all__ = [
32
+ "resolve_attack",
33
+ "KillingAttackAction",
34
+ "RangedAttackAction",
35
+ "StrikeAction",
36
+ ]
@@ -0,0 +1,396 @@
1
+ """Area of Effect targeting computations for HERO System 6E.
2
+
3
+ Shapes: Radius, Cone, Line, Selective (post-filter).
4
+ Modifier: Explosion (DC falloff by distance from epicenter).
5
+
6
+ The basic shape methods are pure math (xy-positions only). Optional scene
7
+ integration kwargs (scene + indirect) layer wall-blocking and hazard-along-
8
+ path resolution on top, per Plan 1 Task 29.
9
+
10
+ Engine has zero runtime deps — only stdlib + this package.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from dataclasses import dataclass, replace
16
+ from typing import TYPE_CHECKING, Literal, Optional
17
+
18
+ if TYPE_CHECKING:
19
+ from kirby_combat.scene.scene import Scene
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Output dataclass
24
+ # ---------------------------------------------------------------------------
25
+
26
+ @dataclass
27
+ class AoEOutcome:
28
+ affected_targets: list[str] # combatant IDs hit
29
+ per_target_dc: dict[str, int] # DC each target takes
30
+ target_dcv_for_aoe: int # = 3 (HERO RAW: targets the hex)
31
+ phase_cost: Literal["half", "full"] # = "full" for all AoE shapes
32
+ hazard_triggers: list = None # type: list[HazardTriggerResult]; populated when a Scene is provided
33
+ affected_constructs: list = None # type: list[str]; destructible construct obj_ids caught in template
34
+
35
+ def __post_init__(self):
36
+ if self.hazard_triggers is None:
37
+ self.hazard_triggers = []
38
+ if self.affected_constructs is None:
39
+ self.affected_constructs = []
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Private geometry helpers
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _distance_2d(a: tuple[float, float], b: tuple[float, float]) -> float:
47
+ """Euclidean distance between two 2-D points."""
48
+ dx = b[0] - a[0]
49
+ dy = b[1] - a[1]
50
+ return math.sqrt(dx * dx + dy * dy)
51
+
52
+
53
+ def _angle_between(origin: tuple[float, float], target: tuple[float, float]) -> float:
54
+ """Angle in radians from origin to target, in [-π, π]."""
55
+ dx = target[0] - origin[0]
56
+ dy = target[1] - origin[1]
57
+ return math.atan2(dy, dx)
58
+
59
+
60
+ def _angle_diff(a: float, b: float) -> float:
61
+ """Smallest absolute angular difference between two angles, result in [0, π]."""
62
+ diff = abs(a - b) % (2 * math.pi)
63
+ if diff > math.pi:
64
+ diff = 2 * math.pi - diff
65
+ return diff
66
+
67
+
68
+ def _perpendicular_distance_to_segment(
69
+ point: tuple[float, float],
70
+ seg_start: tuple[float, float],
71
+ seg_end: tuple[float, float],
72
+ ) -> float:
73
+ """Perpendicular distance from *point* to the line segment [seg_start, seg_end].
74
+
75
+ The projection parameter t is clamped to [0, 1] so that points beyond the
76
+ endpoints use the endpoint distance rather than the infinite-line distance.
77
+ """
78
+ sx, sy = seg_start
79
+ ex, ey = seg_end
80
+ px, py = point
81
+
82
+ dx = ex - sx
83
+ dy = ey - sy
84
+ seg_len_sq = dx * dx + dy * dy
85
+
86
+ if seg_len_sq == 0.0:
87
+ # Degenerate segment — treat as a point
88
+ return _distance_2d(point, seg_start)
89
+
90
+ # Parameter t for the projection of point onto the infinite line
91
+ t = ((px - sx) * dx + (py - sy) * dy) / seg_len_sq
92
+ # Clamp to segment extent
93
+ t = max(0.0, min(1.0, t))
94
+
95
+ # Closest point on segment
96
+ closest_x = sx + t * dx
97
+ closest_y = sy + t * dy
98
+
99
+ return _distance_2d(point, (closest_x, closest_y))
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Construct geometry helpers
104
+ # ---------------------------------------------------------------------------
105
+
106
+ def _construct_point(c) -> "tuple[float, float] | None":
107
+ """Return a representative 2-D point for a Construct (segment midpoint or polygon centroid)."""
108
+ if c.segment is not None:
109
+ a, b = c.segment
110
+ return ((a.x + b.x) / 2.0, (a.y + b.y) / 2.0)
111
+ if c.polygon_xy:
112
+ xs = [p[0] for p in c.polygon_xy]
113
+ ys = [p[1] for p in c.polygon_xy]
114
+ return (sum(xs) / len(xs), sum(ys) / len(ys))
115
+ return None
116
+
117
+
118
+ def _constructs_in_radius(epicenter, radius_m, constructs) -> list[str]:
119
+ """Return obj_ids of destructible constructs whose representative point is within radius_m."""
120
+ out = []
121
+ for c in constructs or []:
122
+ if not c.destructible:
123
+ continue
124
+ pt = _construct_point(c)
125
+ if pt is None:
126
+ continue
127
+ if (pt[0] - epicenter[0]) ** 2 + (pt[1] - epicenter[1]) ** 2 <= radius_m * radius_m:
128
+ out.append(c.obj_id)
129
+ return out
130
+
131
+
132
+ def _constructs_in_cone(origin, actual_direction, half_angle_rad, length_m, constructs) -> list[str]:
133
+ """Return obj_ids of destructible constructs within the cone template."""
134
+ out = []
135
+ for c in constructs or []:
136
+ if not c.destructible:
137
+ continue
138
+ pt = _construct_point(c)
139
+ if pt is None:
140
+ continue
141
+ dist = _distance_2d(origin, pt)
142
+ if dist > length_m:
143
+ continue
144
+ target_angle = _angle_between(origin, pt)
145
+ diff = _angle_diff(target_angle, actual_direction)
146
+ if diff <= half_angle_rad:
147
+ out.append(c.obj_id)
148
+ return out
149
+
150
+
151
+ def _constructs_in_line(start, end, half_width, constructs) -> list[str]:
152
+ """Return obj_ids of destructible constructs within the line template."""
153
+ out = []
154
+ for c in constructs or []:
155
+ if not c.destructible:
156
+ continue
157
+ pt = _construct_point(c)
158
+ if pt is None:
159
+ continue
160
+ perp_dist = _perpendicular_distance_to_segment(pt, start, end)
161
+ if perp_dist <= half_width:
162
+ out.append(c.obj_id)
163
+ return out
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Main class
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def _scene_filter_los(
171
+ scene: "Scene",
172
+ origin_xy: tuple[float, float],
173
+ origin_z: float,
174
+ affected: list[str],
175
+ indirect: bool,
176
+ ) -> list[str]:
177
+ """Drop combatants whose LoS from the AoE origin is blocked.
178
+
179
+ Per 6E1 p339, Indirect bypasses obstacles, so when indirect=True every
180
+ affected target stays in the list.
181
+ """
182
+ from kirby_combat.scene.scene import Position
183
+ from kirby_combat.scene.geometry import line_of_sight_clear
184
+
185
+ if indirect:
186
+ return list(affected)
187
+ out: list[str] = []
188
+ origin_pos = Position(x=origin_xy[0], y=origin_xy[1], z=origin_z)
189
+ for cid in affected:
190
+ target_pos = scene.combatant_positions.get(cid)
191
+ if target_pos is None:
192
+ out.append(cid) # no positional data — keep
193
+ continue
194
+ if line_of_sight_clear(origin_pos, target_pos, scene.walls):
195
+ out.append(cid)
196
+ return out
197
+
198
+
199
+ def _hazards_along_path(
200
+ scene: "Scene",
201
+ origin_xy: tuple[float, float],
202
+ end_xy: tuple[float, float],
203
+ elevation_z: float,
204
+ ) -> list:
205
+ """Return HazardTriggerResults for hazards the path crosses (no combatants).
206
+
207
+ Useful for AoE Line attacks: the projectile may strike a hazard mid-path
208
+ even before reaching its end point. We synthesize a single virtual
209
+ 'aoe_path' combatant to use the existing crossing-detection code.
210
+ """
211
+ from kirby_combat.scene.scene import Position
212
+ from kirby_combat.scene.hazards import compute_hazard_triggers
213
+
214
+ before = {"_aoe_path_": Position(x=origin_xy[0], y=origin_xy[1], z=elevation_z)}
215
+ after = {"_aoe_path_": Position(x=end_xy[0], y=end_xy[1], z=elevation_z)}
216
+ return compute_hazard_triggers(
217
+ scene=scene,
218
+ combatant_positions_before=before,
219
+ combatant_positions_after=after,
220
+ phase="movement",
221
+ )
222
+
223
+
224
+ class AreaOfEffect:
225
+ name: str = "area_of_effect"
226
+
227
+ @staticmethod
228
+ def compute_radius(
229
+ *,
230
+ base_dc: int,
231
+ epicenter: tuple[float, float],
232
+ radius_m: float,
233
+ combatant_positions: dict[str, tuple[float, float]],
234
+ explosion: bool = False,
235
+ scene: Optional["Scene"] = None,
236
+ indirect: bool = False,
237
+ constructs: list | None = None,
238
+ ) -> AoEOutcome:
239
+ """Compute a circular AoE.
240
+
241
+ All combatants within *radius_m* of *epicenter* are affected.
242
+ If *explosion* is True, per-target DC is reduced by 1 per 2 m from epicenter
243
+ (minimum 0).
244
+ """
245
+ affected: list[str] = []
246
+ per_target_dc: dict[str, int] = {}
247
+
248
+ for cid, pos in combatant_positions.items():
249
+ dist = _distance_2d(epicenter, pos)
250
+ if dist <= radius_m:
251
+ affected.append(cid)
252
+ if explosion:
253
+ dc = max(0, base_dc - math.floor(dist / 2))
254
+ else:
255
+ dc = base_dc
256
+ per_target_dc[cid] = dc
257
+
258
+ # Scene-aware filter: drop targets whose LoS from epicenter is blocked
259
+ if scene is not None and not indirect:
260
+ # Use mean z of all hit targets, or scene-floor if no targets
261
+ origin_z = (
262
+ sum(scene.combatant_positions[cid].z for cid in affected if cid in scene.combatant_positions)
263
+ / max(1, len(affected)) if affected else scene.bounds.min_z
264
+ )
265
+ kept = set(_scene_filter_los(scene, epicenter, origin_z, affected, indirect=False))
266
+ affected = [cid for cid in affected if cid in kept]
267
+ per_target_dc = {cid: dc for cid, dc in per_target_dc.items() if cid in kept}
268
+
269
+ return AoEOutcome(
270
+ affected_targets=affected,
271
+ per_target_dc=per_target_dc,
272
+ target_dcv_for_aoe=3,
273
+ phase_cost="full",
274
+ affected_constructs=_constructs_in_radius(epicenter, radius_m, constructs),
275
+ )
276
+
277
+ @staticmethod
278
+ def compute_cone(
279
+ *,
280
+ base_dc: int,
281
+ origin: tuple[float, float],
282
+ direction_rad: float,
283
+ half_angle_rad: float = math.pi / 6, # 30° → 60° total cone
284
+ length_m: float,
285
+ combatant_positions: dict[str, tuple[float, float]],
286
+ scene: Optional["Scene"] = None,
287
+ indirect: bool = False,
288
+ attacker_facing_rad: Optional[float] = None,
289
+ constructs: list | None = None,
290
+ ) -> AoEOutcome:
291
+ """Compute a cone-shaped AoE.
292
+
293
+ A combatant is hit if:
294
+ 1. Their distance from *origin* is ≤ *length_m*.
295
+ 2. The angle from *origin* to the combatant is within *half_angle_rad* of
296
+ *direction_rad* (accounting for wraparound).
297
+ """
298
+ affected: list[str] = []
299
+ per_target_dc: dict[str, int] = {}
300
+
301
+ # If attacker_facing is given and direction_rad is the default, use facing.
302
+ actual_direction = attacker_facing_rad if attacker_facing_rad is not None else direction_rad
303
+
304
+ for cid, pos in combatant_positions.items():
305
+ dist = _distance_2d(origin, pos)
306
+ if dist > length_m:
307
+ continue
308
+
309
+ target_angle = _angle_between(origin, pos)
310
+ diff = _angle_diff(target_angle, actual_direction)
311
+ if diff <= half_angle_rad:
312
+ affected.append(cid)
313
+ per_target_dc[cid] = base_dc
314
+
315
+ if scene is not None and not indirect:
316
+ origin_z = scene.bounds.min_z
317
+ for cid in affected:
318
+ if cid in scene.combatant_positions:
319
+ origin_z = scene.combatant_positions[cid].z
320
+ break
321
+ kept = set(_scene_filter_los(scene, origin, origin_z, affected, indirect=False))
322
+ affected = [cid for cid in affected if cid in kept]
323
+ per_target_dc = {cid: dc for cid, dc in per_target_dc.items() if cid in kept}
324
+
325
+ return AoEOutcome(
326
+ affected_targets=affected,
327
+ per_target_dc=per_target_dc,
328
+ target_dcv_for_aoe=3,
329
+ phase_cost="full",
330
+ affected_constructs=_constructs_in_cone(origin, actual_direction, half_angle_rad, length_m, constructs),
331
+ )
332
+
333
+ @staticmethod
334
+ def compute_line(
335
+ *,
336
+ base_dc: int,
337
+ start: tuple[float, float],
338
+ end: tuple[float, float],
339
+ width_m: float,
340
+ combatant_positions: dict[str, tuple[float, float]],
341
+ scene: Optional["Scene"] = None,
342
+ indirect: bool = False,
343
+ elevation_z: float = 0.0,
344
+ constructs: list | None = None,
345
+ ) -> AoEOutcome:
346
+ """Compute a line-shaped AoE.
347
+
348
+ A combatant is hit if their perpendicular distance to the line segment
349
+ [*start*, *end*] is ≤ *width_m* / 2. Points beyond the segment endpoints
350
+ use the endpoint distance (clamped projection).
351
+ """
352
+ half_width = width_m / 2.0
353
+ affected: list[str] = []
354
+ per_target_dc: dict[str, int] = {}
355
+
356
+ for cid, pos in combatant_positions.items():
357
+ perp_dist = _perpendicular_distance_to_segment(pos, start, end)
358
+ if perp_dist <= half_width:
359
+ affected.append(cid)
360
+ per_target_dc[cid] = base_dc
361
+
362
+ hazard_triggers: list = []
363
+ if scene is not None:
364
+ if not indirect:
365
+ kept = set(_scene_filter_los(scene, start, elevation_z, affected, indirect=False))
366
+ affected = [cid for cid in affected if cid in kept]
367
+ per_target_dc = {cid: dc for cid, dc in per_target_dc.items() if cid in kept}
368
+ # Hazards crossed by the line projectile path
369
+ hazard_triggers = _hazards_along_path(scene, start, end, elevation_z)
370
+
371
+ return AoEOutcome(
372
+ affected_targets=affected,
373
+ per_target_dc=per_target_dc,
374
+ target_dcv_for_aoe=3,
375
+ phase_cost="full",
376
+ hazard_triggers=hazard_triggers,
377
+ affected_constructs=_constructs_in_line(start, end, half_width, constructs),
378
+ )
379
+
380
+ @staticmethod
381
+ def selective_filter(
382
+ outcome: AoEOutcome,
383
+ *,
384
+ excluded_ids: set[str],
385
+ ) -> AoEOutcome:
386
+ """Return a new AoEOutcome with *excluded_ids* removed.
387
+
388
+ Uses dataclasses.replace so the original outcome is not mutated.
389
+ """
390
+ new_affected = [t for t in outcome.affected_targets if t not in excluded_ids]
391
+ new_per_dc = {k: v for k, v in outcome.per_target_dc.items() if k not in excluded_ids}
392
+ return replace(
393
+ outcome,
394
+ affected_targets=new_affected,
395
+ per_target_dc=new_per_dc,
396
+ )