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.
- kirby_combat-0.3.28/LICENSE +21 -0
- kirby_combat-0.3.28/PKG-INFO +95 -0
- kirby_combat-0.3.28/README.md +76 -0
- kirby_combat-0.3.28/kirby_combat/__init__.py +2 -0
- kirby_combat-0.3.28/kirby_combat/actions/__init__.py +36 -0
- kirby_combat-0.3.28/kirby_combat/actions/area_of_effect.py +396 -0
- kirby_combat-0.3.28/kirby_combat/actions/autofire.py +129 -0
- kirby_combat-0.3.28/kirby_combat/actions/base.py +221 -0
- kirby_combat-0.3.28/kirby_combat/actions/brace.py +102 -0
- kirby_combat-0.3.28/kirby_combat/actions/cv_parser.py +67 -0
- kirby_combat-0.3.28/kirby_combat/actions/dive_for_cover.py +156 -0
- kirby_combat-0.3.28/kirby_combat/actions/entangle.py +207 -0
- kirby_combat-0.3.28/kirby_combat/actions/flash.py +199 -0
- kirby_combat-0.3.28/kirby_combat/actions/grab.py +231 -0
- kirby_combat-0.3.28/kirby_combat/actions/haymaker.py +81 -0
- kirby_combat-0.3.28/kirby_combat/actions/held_action.py +300 -0
- kirby_combat-0.3.28/kirby_combat/actions/killing.py +6 -0
- kirby_combat-0.3.28/kirby_combat/actions/martial_arts.py +255 -0
- kirby_combat-0.3.28/kirby_combat/actions/move_by.py +76 -0
- kirby_combat-0.3.28/kirby_combat/actions/move_through.py +71 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/__init__.py +18 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/base.py +155 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/flight.py +30 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/knockback_movement.py +189 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/leaping.py +30 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/running.py +26 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/swimming.py +29 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/teleportation.py +31 -0
- kirby_combat-0.3.28/kirby_combat/actions/movement/tunneling.py +31 -0
- kirby_combat-0.3.28/kirby_combat/actions/multiple_attack.py +41 -0
- kirby_combat-0.3.28/kirby_combat/actions/pulling_punch.py +79 -0
- kirby_combat-0.3.28/kirby_combat/actions/ranged.py +6 -0
- kirby_combat-0.3.28/kirby_combat/actions/rapid_fire.py +31 -0
- kirby_combat-0.3.28/kirby_combat/actions/reactive/__init__.py +13 -0
- kirby_combat-0.3.28/kirby_combat/actions/reactive/abort.py +45 -0
- kirby_combat-0.3.28/kirby_combat/actions/reactive/block.py +63 -0
- kirby_combat-0.3.28/kirby_combat/actions/reactive/dodge.py +37 -0
- kirby_combat-0.3.28/kirby_combat/actions/set_action.py +84 -0
- kirby_combat-0.3.28/kirby_combat/actions/strike.py +6 -0
- kirby_combat-0.3.28/kirby_combat/actions/sweep.py +21 -0
- kirby_combat-0.3.28/kirby_combat/actions/throw.py +123 -0
- kirby_combat-0.3.28/kirby_combat/actions/trigger.py +158 -0
- kirby_combat-0.3.28/kirby_combat/breakables/__init__.py +14 -0
- kirby_combat-0.3.28/kirby_combat/breakables/object_combatant.py +82 -0
- kirby_combat-0.3.28/kirby_combat/breakables/structure.py +120 -0
- kirby_combat-0.3.28/kirby_combat/dice/__init__.py +5 -0
- kirby_combat-0.3.28/kirby_combat/dice/fake.py +42 -0
- kirby_combat-0.3.28/kirby_combat/dice/roller.py +49 -0
- kirby_combat-0.3.28/kirby_combat/gm/__init__.py +24 -0
- kirby_combat-0.3.28/kirby_combat/gm/gm_attack.py +64 -0
- kirby_combat-0.3.28/kirby_combat/gm/overrides.py +202 -0
- kirby_combat-0.3.28/kirby_combat/gm/spawn_despawn.py +78 -0
- kirby_combat-0.3.28/kirby_combat/hero_view.py +1619 -0
- kirby_combat-0.3.28/kirby_combat/masscombat/__init__.py +14 -0
- kirby_combat-0.3.28/kirby_combat/masscombat/resolution.py +76 -0
- kirby_combat-0.3.28/kirby_combat/masscombat/unit.py +76 -0
- kirby_combat-0.3.28/kirby_combat/mental/__init__.py +36 -0
- kirby_combat-0.3.28/kirby_combat/mental/mental_blast.py +53 -0
- kirby_combat-0.3.28/kirby_combat/mental/mental_combat.py +63 -0
- kirby_combat-0.3.28/kirby_combat/mental/mental_entangle.py +107 -0
- kirby_combat-0.3.28/kirby_combat/mental/mental_illusion.py +98 -0
- kirby_combat-0.3.28/kirby_combat/mental/mind_control.py +73 -0
- kirby_combat-0.3.28/kirby_combat/mental/telepathy.py +55 -0
- kirby_combat-0.3.28/kirby_combat/models.py +323 -0
- kirby_combat-0.3.28/kirby_combat/perception.py +585 -0
- kirby_combat-0.3.28/kirby_combat/pre_attacks/__init__.py +10 -0
- kirby_combat-0.3.28/kirby_combat/pre_attacks/presence.py +85 -0
- kirby_combat-0.3.28/kirby_combat/resolution/__init__.py +1 -0
- kirby_combat-0.3.28/kirby_combat/resolution/adjustments.py +78 -0
- kirby_combat-0.3.28/kirby_combat/resolution/damage.py +250 -0
- kirby_combat-0.3.28/kirby_combat/resolution/defense.py +311 -0
- kirby_combat-0.3.28/kirby_combat/resolution/knockback.py +171 -0
- kirby_combat-0.3.28/kirby_combat/resolution/line_of_sight.py +107 -0
- kirby_combat-0.3.28/kirby_combat/resolution/object_damage.py +77 -0
- kirby_combat-0.3.28/kirby_combat/resolution/recovery.py +106 -0
- kirby_combat-0.3.28/kirby_combat/resolution/status.py +46 -0
- kirby_combat-0.3.28/kirby_combat/resolution/to_hit.py +159 -0
- kirby_combat-0.3.28/kirby_combat/scene/__init__.py +20 -0
- kirby_combat-0.3.28/kirby_combat/scene/construct.py +154 -0
- kirby_combat-0.3.28/kirby_combat/scene/cover.py +124 -0
- kirby_combat-0.3.28/kirby_combat/scene/effects.py +74 -0
- kirby_combat-0.3.28/kirby_combat/scene/falling.py +94 -0
- kirby_combat-0.3.28/kirby_combat/scene/geometry.py +215 -0
- kirby_combat-0.3.28/kirby_combat/scene/hazards.py +115 -0
- kirby_combat-0.3.28/kirby_combat/scene/movement_legality.py +424 -0
- kirby_combat-0.3.28/kirby_combat/scene/scene.py +198 -0
- kirby_combat-0.3.28/kirby_combat/scene/visibility.py +279 -0
- kirby_combat-0.3.28/kirby_combat/serialization/__init__.py +5 -0
- kirby_combat-0.3.28/kirby_combat/serialization/from_dict.py +222 -0
- kirby_combat-0.3.28/kirby_combat/serialization/to_dict.py +82 -0
- kirby_combat-0.3.28/kirby_combat/session/__init__.py +39 -0
- kirby_combat-0.3.28/kirby_combat/session/apply.py +77 -0
- kirby_combat-0.3.28/kirby_combat/session/combat_session.py +112 -0
- kirby_combat-0.3.28/kirby_combat/session/effects.py +179 -0
- kirby_combat-0.3.28/kirby_combat/session/events.py +259 -0
- kirby_combat-0.3.28/kirby_combat/session/rewind.py +33 -0
- kirby_combat-0.3.28/kirby_combat/session/timeline.py +72 -0
- kirby_combat-0.3.28/kirby_combat/tables.py +281 -0
- kirby_combat-0.3.28/kirby_combat/template.py +94 -0
- kirby_combat-0.3.28/kirby_combat/vehicles/__init__.py +26 -0
- kirby_combat-0.3.28/kirby_combat/vehicles/controls.py +91 -0
- kirby_combat-0.3.28/kirby_combat/vehicles/passenger.py +84 -0
- kirby_combat-0.3.28/kirby_combat/vehicles/ramming.py +72 -0
- kirby_combat-0.3.28/kirby_combat/vehicles/vehicle.py +97 -0
- kirby_combat-0.3.28/kirby_combat.egg-info/PKG-INFO +95 -0
- kirby_combat-0.3.28/kirby_combat.egg-info/SOURCES.txt +154 -0
- kirby_combat-0.3.28/kirby_combat.egg-info/dependency_links.txt +1 -0
- kirby_combat-0.3.28/kirby_combat.egg-info/requires.txt +8 -0
- kirby_combat-0.3.28/kirby_combat.egg-info/top_level.txt +1 -0
- kirby_combat-0.3.28/pyproject.toml +34 -0
- kirby_combat-0.3.28/setup.cfg +4 -0
- kirby_combat-0.3.28/tests/test_actions.py +98 -0
- kirby_combat-0.3.28/tests/test_adjustments.py +86 -0
- kirby_combat-0.3.28/tests/test_aoe.py +165 -0
- kirby_combat-0.3.28/tests/test_aoe_constructs.py +60 -0
- kirby_combat-0.3.28/tests/test_aoe_scene_integration.py +195 -0
- kirby_combat-0.3.28/tests/test_attack_los.py +101 -0
- kirby_combat-0.3.28/tests/test_attack_power_avad.py +30 -0
- kirby_combat-0.3.28/tests/test_attack_view_multipower.py +249 -0
- kirby_combat-0.3.28/tests/test_avad_resolution.py +172 -0
- kirby_combat-0.3.28/tests/test_cv_parser.py +47 -0
- kirby_combat-0.3.28/tests/test_damage.py +274 -0
- kirby_combat-0.3.28/tests/test_damage_reduction_negation.py +286 -0
- kirby_combat-0.3.28/tests/test_defense.py +185 -0
- kirby_combat-0.3.28/tests/test_dice.py +68 -0
- kirby_combat-0.3.28/tests/test_entangle.py +232 -0
- kirby_combat-0.3.28/tests/test_flash.py +222 -0
- kirby_combat-0.3.28/tests/test_framework_view.py +32 -0
- kirby_combat-0.3.28/tests/test_grab_throw.py +229 -0
- kirby_combat-0.3.28/tests/test_held_release.py +144 -0
- kirby_combat-0.3.28/tests/test_hero_capabilities.py +20 -0
- kirby_combat-0.3.28/tests/test_hero_combatant_skeleton.py +388 -0
- kirby_combat-0.3.28/tests/test_knockback.py +222 -0
- kirby_combat-0.3.28/tests/test_maneuver_view.py +101 -0
- kirby_combat-0.3.28/tests/test_martial_arts.py +184 -0
- kirby_combat-0.3.28/tests/test_martial_maneuvers_table.py +78 -0
- kirby_combat-0.3.28/tests/test_move_by_through.py +139 -0
- kirby_combat-0.3.28/tests/test_movement_base.py +163 -0
- kirby_combat-0.3.28/tests/test_movement_powers.py +122 -0
- kirby_combat-0.3.28/tests/test_movement_reach.py +420 -0
- kirby_combat-0.3.28/tests/test_movement_view.py +348 -0
- kirby_combat-0.3.28/tests/test_multi_attack.py +189 -0
- kirby_combat-0.3.28/tests/test_naked_resistant.py +196 -0
- kirby_combat-0.3.28/tests/test_object_identity.py +146 -0
- kirby_combat-0.3.28/tests/test_object_throw.py +35 -0
- kirby_combat-0.3.28/tests/test_perception.py +932 -0
- kirby_combat-0.3.28/tests/test_reach.py +80 -0
- kirby_combat-0.3.28/tests/test_reactive.py +134 -0
- kirby_combat-0.3.28/tests/test_recovery.py +168 -0
- kirby_combat-0.3.28/tests/test_status.py +82 -0
- kirby_combat-0.3.28/tests/test_surface_blocks_los.py +122 -0
- kirby_combat-0.3.28/tests/test_tables.py +177 -0
- kirby_combat-0.3.28/tests/test_tactical_modifiers.py +328 -0
- kirby_combat-0.3.28/tests/test_to_hit.py +382 -0
- kirby_combat-0.3.28/tests/test_trigger.py +180 -0
- 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,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
|
+
)
|