kirby-world 0.1.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 (42) hide show
  1. kirby_world-0.1.0/LICENSE +149 -0
  2. kirby_world-0.1.0/PKG-INFO +56 -0
  3. kirby_world-0.1.0/README.md +39 -0
  4. kirby_world-0.1.0/kirby_world/__init__.py +57 -0
  5. kirby_world-0.1.0/kirby_world/construct.py +275 -0
  6. kirby_world-0.1.0/kirby_world/cover.py +348 -0
  7. kirby_world-0.1.0/kirby_world/falling.py +94 -0
  8. kirby_world-0.1.0/kirby_world/generate.py +256 -0
  9. kirby_world-0.1.0/kirby_world/geometry.py +246 -0
  10. kirby_world-0.1.0/kirby_world/hazards.py +114 -0
  11. kirby_world-0.1.0/kirby_world/movement_legality.py +501 -0
  12. kirby_world-0.1.0/kirby_world/occupancy.py +48 -0
  13. kirby_world-0.1.0/kirby_world/scene.py +523 -0
  14. kirby_world-0.1.0/kirby_world/visibility.py +340 -0
  15. kirby_world-0.1.0/kirby_world/world.py +29 -0
  16. kirby_world-0.1.0/kirby_world.egg-info/PKG-INFO +56 -0
  17. kirby_world-0.1.0/kirby_world.egg-info/SOURCES.txt +40 -0
  18. kirby_world-0.1.0/kirby_world.egg-info/dependency_links.txt +1 -0
  19. kirby_world-0.1.0/kirby_world.egg-info/requires.txt +6 -0
  20. kirby_world-0.1.0/kirby_world.egg-info/top_level.txt +1 -0
  21. kirby_world-0.1.0/pyproject.toml +33 -0
  22. kirby_world-0.1.0/setup.cfg +4 -0
  23. kirby_world-0.1.0/tests/test_a_place_without_a_fight.py +59 -0
  24. kirby_world-0.1.0/tests/test_climb_difficulty.py +31 -0
  25. kirby_world-0.1.0/tests/test_construct.py +123 -0
  26. kirby_world-0.1.0/tests/test_cover.py +329 -0
  27. kirby_world-0.1.0/tests/test_cover_is_per_threat.py +89 -0
  28. kirby_world-0.1.0/tests/test_cover_protects_the_man_behind_it.py +100 -0
  29. kirby_world-0.1.0/tests/test_falling.py +112 -0
  30. kirby_world-0.1.0/tests/test_flank_candidates.py +112 -0
  31. kirby_world-0.1.0/tests/test_furnishings.py +333 -0
  32. kirby_world-0.1.0/tests/test_generate.py +151 -0
  33. kirby_world-0.1.0/tests/test_geometry.py +142 -0
  34. kirby_world-0.1.0/tests/test_hazards.py +233 -0
  35. kirby_world-0.1.0/tests/test_leaf.py +51 -0
  36. kirby_world-0.1.0/tests/test_los_wall_top.py +31 -0
  37. kirby_world-0.1.0/tests/test_scene.py +110 -0
  38. kirby_world-0.1.0/tests/test_vantage_support.py +219 -0
  39. kirby_world-0.1.0/tests/test_visibility.py +141 -0
  40. kirby_world-0.1.0/tests/test_vocabulary.py +121 -0
  41. kirby_world-0.1.0/tests/test_wall_defenses.py +47 -0
  42. kirby_world-0.1.0/tests/test_wall_top_surface.py +99 -0
@@ -0,0 +1,149 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ ## Acceptance
6
+
7
+ In order to get any license under these terms, you must agree
8
+ to them as both strict obligations and conditions to all
9
+ your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the
14
+ software to do everything you might do with the software
15
+ that would otherwise infringe the licensor's copyright
16
+ in it for any permitted purpose. However, you may
17
+ only distribute the software according to [Distribution
18
+ License](#distribution-license) and make changes or new works
19
+ based on the software according to [Changes and New Works
20
+ License](#changes-and-new-works-license).
21
+
22
+ ## Distribution License
23
+
24
+ The licensor grants you an additional copyright license
25
+ to distribute copies of the software. Your license
26
+ to distribute covers distributing the software with
27
+ changes and new works permitted by [Changes and New Works
28
+ License](#changes-and-new-works-license).
29
+
30
+ ## Notices
31
+
32
+ You must ensure that anyone who gets a copy of any part of
33
+ the software from you also gets a copy of these terms or the
34
+ URL for them above, as well as copies of any plain-text lines
35
+ beginning with `Required Notice:` that the licensor provided
36
+ with the software. For example:
37
+
38
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
39
+
40
+ ## Changes and New Works License
41
+
42
+ The licensor grants you an additional copyright license to
43
+ make changes and new works based on the software for any
44
+ permitted purpose.
45
+
46
+ ## Patent License
47
+
48
+ The licensor grants you a patent license for the software that
49
+ covers patent claims the licensor can license, or becomes able
50
+ to license, that you would infringe by using the software.
51
+
52
+ ## Noncommercial Purposes
53
+
54
+ Any noncommercial purpose is a permitted purpose.
55
+
56
+ ## Personal Uses
57
+
58
+ Personal use for research, experiment, and testing for
59
+ the benefit of public knowledge, personal study, private
60
+ entertainment, hobby projects, amateur pursuits, or religious
61
+ observance, without any anticipated commercial application,
62
+ is use for a permitted purpose.
63
+
64
+ ## Noncommercial Organizations
65
+
66
+ Use by any charitable organization, educational institution,
67
+ public research organization, public safety or health
68
+ organization, environmental protection organization,
69
+ or government institution is use for a permitted purpose
70
+ regardless of the source of funding or obligations resulting
71
+ from the funding.
72
+
73
+ ## Fair Use
74
+
75
+ You may have "fair use" rights for the software under the
76
+ law. These terms do not limit them.
77
+
78
+ ## No Other Rights
79
+
80
+ These terms do not allow you to sublicense or transfer any of
81
+ your licenses to anyone else, or prevent the licensor from
82
+ granting licenses to anyone else. These terms do not imply
83
+ any other licenses.
84
+
85
+ ## Patent Defense
86
+
87
+ If you make any written claim that the software infringes or
88
+ contributes to infringement of any patent, your patent license
89
+ for the software granted under these terms ends immediately. If
90
+ your company makes such a claim, your patent license ends
91
+ immediately for work on behalf of your company.
92
+
93
+ ## Violations
94
+
95
+ The first time you are notified in writing that you have
96
+ violated any of these terms, or done anything with the software
97
+ not covered by your licenses, your licenses can nonetheless
98
+ continue if you come into full compliance with these terms,
99
+ and take practical steps to correct past violations, within
100
+ 32 days of receiving notice. Otherwise, all your licenses
101
+ end immediately.
102
+
103
+ ## No Liability
104
+
105
+ ***As far as the law allows, the software comes as is, without
106
+ any warranty or condition, and the licensor will not be liable
107
+ to you for any damages arising out of these terms or the use
108
+ or nature of the software, under any kind of legal claim.***
109
+
110
+ ## Definitions
111
+
112
+ The **licensor** is the individual or entity offering these
113
+ terms, and the **software** is the software the licensor makes
114
+ available under these terms.
115
+
116
+ **You** refers to the individual or entity agreeing to these
117
+ terms.
118
+
119
+ **Your company** is any legal entity, sole proprietorship,
120
+ or other kind of organization that you work for, plus all
121
+ organizations that have control over, are under the control of,
122
+ or are under common control with that organization. **Control**
123
+ means ownership of substantially all the assets of an entity,
124
+ or the power to direct its management and policies by vote,
125
+ contract, or otherwise. Control can be direct or indirect.
126
+
127
+ **Your licenses** are all the licenses granted to you for the
128
+ software under these terms.
129
+
130
+ **Use** means anything you do with the software requiring one
131
+ of your licenses.
132
+
133
+
134
+ ================================================================================
135
+ NOTES SPECIFIC TO kirby-terrain
136
+ ================================================================================
137
+
138
+ Required Notice: Copyright (c) 2026 Peter D Bethke
139
+
140
+ (The line above begins with `Required Notice:` deliberately -- see the
141
+ Notices section of the licence. It must travel with any copy of this
142
+ software or any part of it.)
143
+
144
+ This package reproduces no rules text or prose from the HERO System rulebook.
145
+ The PD, ED and BODY values in `durability.py` are bare game statistics —
146
+ numbers, not the rule or the writing around them — transcribed for use by
147
+ this platform's engines.
148
+
149
+ HERO System and HERO Designer are the property of their respective owners.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: kirby-world
3
+ Version: 0.1.0
4
+ Summary: Places for the Kirby HERO System platform: Worlds, Scenes and the geometry inside them
5
+ Author: PeterB
6
+ License: PolyForm Noncommercial License 1.0.0
7
+ Project-URL: Repository, https://github.com/pdbethke/kirby-world
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: kirby-terrain>=0.1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.6; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # kirby-world
19
+
20
+ Places for the Kirby HERO System platform: a `World` of `Scene`s, and the
21
+ geometry inside them — surfaces, walls, furnishings, hazards, constructs,
22
+ cover, falling, line of sight and per-mode movement reachability.
23
+
24
+ A place is not a fight. A house with five people doing chores, and what is
25
+ reachable from the kitchen, needs nothing but this package:
26
+
27
+ ```python
28
+ from kirby_world import AmbientConditions, Position, Scene, SceneBounds
29
+ from kirby_world.movement_legality import movement_reach
30
+
31
+ house = Scene(id="house", name="Safehouse", bounds=SceneBounds(0, 0, 0, 20, 20, 4),
32
+ surfaces=[], walls=[], hazards=[], ambient=AmbientConditions())
33
+ house = house.place_combatant("ana", Position(1.0, 0.0, 0.0))
34
+ movement_reach("flight", Position(0, 0, 1), Position(5, 0, 1), 10.0, house).reachable
35
+ ```
36
+
37
+ ## Where it sits
38
+
39
+ ```
40
+ kirby-terrain what a thing is made of no deps
41
+ kirby-world places -> kirby-terrain
42
+ kirby-combat the fight -> kirby-world, kirby-cost, kirby-dice
43
+ ```
44
+
45
+ Questions about the person moving rather than the place — is he Stunned,
46
+ what has this building already absorbed — belong to the fight. They arrive
47
+ here as plain values (`mover_can_move`, a `damage` ledger), never as the
48
+ fight itself. `tests/test_a_place_without_a_fight.py` enforces that by
49
+ making the fight engine unimportable and building a house anyway.
50
+
51
+ Split out of kirby-combat at 0.20.0, where `kirby_combat.scene` remains as a
52
+ re-export of this package.
53
+
54
+ ## License
55
+
56
+ PolyForm Noncommercial 1.0.0. Not affiliated with or endorsed by Hero Games.
@@ -0,0 +1,39 @@
1
+ # kirby-world
2
+
3
+ Places for the Kirby HERO System platform: a `World` of `Scene`s, and the
4
+ geometry inside them — surfaces, walls, furnishings, hazards, constructs,
5
+ cover, falling, line of sight and per-mode movement reachability.
6
+
7
+ A place is not a fight. A house with five people doing chores, and what is
8
+ reachable from the kitchen, needs nothing but this package:
9
+
10
+ ```python
11
+ from kirby_world import AmbientConditions, Position, Scene, SceneBounds
12
+ from kirby_world.movement_legality import movement_reach
13
+
14
+ house = Scene(id="house", name="Safehouse", bounds=SceneBounds(0, 0, 0, 20, 20, 4),
15
+ surfaces=[], walls=[], hazards=[], ambient=AmbientConditions())
16
+ house = house.place_combatant("ana", Position(1.0, 0.0, 0.0))
17
+ movement_reach("flight", Position(0, 0, 1), Position(5, 0, 1), 10.0, house).reachable
18
+ ```
19
+
20
+ ## Where it sits
21
+
22
+ ```
23
+ kirby-terrain what a thing is made of no deps
24
+ kirby-world places -> kirby-terrain
25
+ kirby-combat the fight -> kirby-world, kirby-cost, kirby-dice
26
+ ```
27
+
28
+ Questions about the person moving rather than the place — is he Stunned,
29
+ what has this building already absorbed — belong to the fight. They arrive
30
+ here as plain values (`mover_can_move`, a `damage` ledger), never as the
31
+ fight itself. `tests/test_a_place_without_a_fight.py` enforces that by
32
+ making the fight engine unimportable and building a house anyway.
33
+
34
+ Split out of kirby-combat at 0.20.0, where `kirby_combat.scene` remains as a
35
+ re-export of this package.
36
+
37
+ ## License
38
+
39
+ PolyForm Noncommercial 1.0.0. Not affiliated with or endorsed by Hero Games.
@@ -0,0 +1,57 @@
1
+ """kirby-world — places: a World of Scenes, and the geometry inside them.
2
+
3
+ Pure: no dice, no rulebook engine, no database. A house with five people in
4
+ it, and what is reachable from the kitchen, needs nothing else installed.
5
+ The fight that may happen in a place lives in kirby-combat, which depends
6
+ on this package and never the other way round.
7
+ """
8
+ from kirby_world.construct import (
9
+ Construct,
10
+ ConstructEffect,
11
+ construct_from_hazard,
12
+ construct_from_spawn_spec,
13
+ construct_from_wall,
14
+ constructs_containing,
15
+ constructs_in,
16
+ )
17
+ from kirby_world.movement_legality import mode_requires_support
18
+ from kirby_world.occupancy import blocked_by_furnishing, position_of
19
+ from kirby_world.scene import (
20
+ AmbientConditions,
21
+ Furnishing,
22
+ Hazard,
23
+ HazardEffect,
24
+ Position,
25
+ Scene,
26
+ SceneBounds,
27
+ Surface,
28
+ Wall,
29
+ is_climbable,
30
+ wall_top_surface,
31
+ )
32
+ from kirby_world.world import World
33
+
34
+ __all__ = [
35
+ "AmbientConditions",
36
+ "Construct",
37
+ "ConstructEffect",
38
+ "Furnishing",
39
+ "Hazard",
40
+ "HazardEffect",
41
+ "Position",
42
+ "Scene",
43
+ "SceneBounds",
44
+ "Surface",
45
+ "Wall",
46
+ "World",
47
+ "blocked_by_furnishing",
48
+ "construct_from_hazard",
49
+ "construct_from_spawn_spec",
50
+ "construct_from_wall",
51
+ "constructs_containing",
52
+ "constructs_in",
53
+ "is_climbable",
54
+ "mode_requires_support",
55
+ "position_of",
56
+ "wall_top_surface",
57
+ ]
@@ -0,0 +1,275 @@
1
+ """Unified placed-entity model. A Construct is a destructible/effect-bearing
2
+ region of the scene — walls, hazard zones, force walls, (reserved) illusion
3
+ zones are all Constructs. Spec 2026-06-09 §1.1.
4
+
5
+ The engine reasons over Constructs for durability + effect resolution; the
6
+ authored Wall/Hazard dataclasses (scene.py) remain the template source and
7
+ project into Constructs via the helpers here. Frozen: a Construct's `body` is
8
+ the CURRENT body for one resolution; damage flows out as events and back via
9
+ driver hydration on the next step.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable, Mapping
14
+ from dataclasses import dataclass, replace
15
+ from typing import Literal
16
+
17
+ from kirby_world.geometry import point_in_polygon_xy
18
+ from kirby_world.scene import Hazard, Position, Wall
19
+
20
+ EffectKind = Literal["damage", "suffocation", "status", "mental"] # "mental" reserved (v1: not resolved)
21
+ EffectGating = Literal["physical_def", "breathing_swimming", "mental_disbelief", "none"]
22
+ EffectTrigger = Literal["on_enter", "on_pass", "every_segment", "passive"]
23
+ ConstructKind = Literal["wall", "hazard_zone", "force_wall", "illusion_zone", "darkness_zone"] # illusion_zone reserved
24
+ Permeability = Literal["porous", "impermeable"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ConstructEffect:
29
+ """A defense-gated consequence for occupants/passers of a Construct."""
30
+ kind: EffectKind
31
+ gating: EffectGating = "none"
32
+ trigger: EffectTrigger = "passive"
33
+ damage_dice: int = 0
34
+ damage_type: str = "normal"
35
+ status_inflicted: str | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Construct:
40
+ """An independently placed region with optional durability and effect."""
41
+ obj_id: str
42
+ kind: ConstructKind
43
+ # geometry: a segment (walls/barriers) OR a polygon (zones) — exactly one
44
+ segment: tuple[Position, Position] | None = None
45
+ polygon_xy: list[tuple[float, float]] | None = None
46
+ elevation_range_m: tuple[float, float] = (0.0, 0.0)
47
+ height_m: float = 0.0
48
+ # blocking / cover
49
+ blocks_los: bool = False
50
+ blocks_movement: bool = False
51
+ permeability: Permeability = "porous"
52
+ cover_level: int = 0
53
+ #: Levels of "Cannot Be Escaped With Teleportation" on the source
54
+ #: Barrier (6E1 p175): a teleport path may not pass this construct
55
+ #: unless the Teleportation carries at least as many levels of
56
+ #: Armor Piercing (movement_legality gates on it). 0 = no effect.
57
+ no_teleport_levels: int = 0
58
+ # durability (both None => indestructible). `body` is CURRENT body.
59
+ #: Can somebody strong enough pick this up and throw it?
60
+ #:
61
+ #: PORTABLE IS A PROPERTY OF THE OBJECT, not a kind of object. `pickup`
62
+ #: used to filter on `kind == "debris"` --- a kind that is not in
63
+ #: `ConstructKind` and that nothing in this engine has ever created, so
64
+ #: `pickup` and `throw_object` were two complete action kinds that
65
+ #: could never fire. A whiskey barrel is not rubble and never becomes
66
+ #: any; a wagon is not a special sort of wall. Both are simply things
67
+ #: light enough to lift, which is what the weight gate already asks.
68
+ portable: bool = False
69
+ def_value: int | None = None
70
+ ed_value: int | None = None # ED, when it differs from PD (6E2 p173)
71
+ resistant: bool = True # False = Normal Defense, not applied vs Killing
72
+ body: int | None = None
73
+ #: The obj_id of the structure this is a FACE of. Rides across from
74
+ #: `Wall.part_of` --- and MUST, because the resolvers act on the
75
+ #: projected Construct, so a face that lost this on the way through
76
+ #: would be demolished as a building instead of breached as a wall.
77
+ part_of: str | None = None
78
+ # effect / provenance
79
+ effect: ConstructEffect | None = None
80
+ source_combatant_id: str | None = None
81
+ created_at_seq: int | None = None
82
+ # sense-affecting (darkness_zone): the Sense Group this zone occludes
83
+ # (e.g. "sight", "mental") and whether the creating combatant sees through it.
84
+ sense_group: str | None = None
85
+ creator_immune: bool = False
86
+
87
+ @property
88
+ def destructible(self) -> bool:
89
+ return self.def_value is not None and self.body is not None
90
+
91
+ @property
92
+ def pd(self) -> int | None:
93
+ """Physical defense. `def_value` has always meant PD; this names it."""
94
+ return self.def_value
95
+
96
+ @property
97
+ def ed(self) -> int | None:
98
+ """Energy defense, falling back to PD when the construct does not state
99
+ one. The fallback is what keeps every pre-existing construct -- here and
100
+ in kirby-api, which constructs these by keyword -- behaving exactly
101
+ as it did before PD and ED were separated."""
102
+ return self.def_value if self.ed_value is None else self.ed_value
103
+
104
+
105
+ def construct_from_spawn_spec(
106
+ *,
107
+ obj_id: str,
108
+ kind: ConstructKind,
109
+ segment: tuple[Position, Position] | None = None,
110
+ polygon_xy: list[tuple[float, float]] | None = None,
111
+ elevation_range_m: tuple[float, float] = (0.0, 0.0),
112
+ height_m: float = 0.0,
113
+ def_value: int | None = None,
114
+ body: int | None = None,
115
+ effect: ConstructEffect | None = None,
116
+ permeability: Permeability | None = None,
117
+ source_combatant_id: str | None = None,
118
+ created_at_seq: int | None = None,
119
+ sense_group: str | None = None,
120
+ creator_immune: bool = False,
121
+ ) -> Construct:
122
+ """Build a spawned Construct (force wall / spawn-on-destroy hazard). A
123
+ force_wall defaults to a LoS+movement blocking impermeable barrier; a
124
+ hazard_zone defaults to porous. the driver computes def/body/geometry from the
125
+ casting power (Plan 2) and passes them here. Spec §1.7."""
126
+ if permeability is None:
127
+ permeability = "impermeable" if kind in ("wall", "force_wall") else "porous"
128
+ blocks = kind in ("wall", "force_wall")
129
+ return Construct(
130
+ obj_id=obj_id, kind=kind, segment=segment, polygon_xy=polygon_xy,
131
+ elevation_range_m=elevation_range_m, height_m=height_m,
132
+ blocks_los=blocks, blocks_movement=blocks, permeability=permeability,
133
+ cover_level=4 if blocks else 0,
134
+ def_value=def_value, body=body, effect=effect,
135
+ source_combatant_id=source_combatant_id, created_at_seq=created_at_seq,
136
+ sense_group=sense_group, creator_immune=creator_immune,
137
+ )
138
+
139
+
140
+ def constructs_containing(pos: Position, constructs: Iterable[Construct]) -> list[Construct]:
141
+ """Return constructs whose polygon contains `pos` in xy within their elevation
142
+ range (segment constructs are never 'containing' a point)."""
143
+ out: list[Construct] = []
144
+ for c in constructs:
145
+ if c.polygon_xy is None:
146
+ continue
147
+ lo, hi = c.elevation_range_m
148
+ if not (lo <= pos.z <= hi):
149
+ continue
150
+ if point_in_polygon_xy((pos.x, pos.y), c.polygon_xy):
151
+ out.append(c)
152
+ return out
153
+
154
+
155
+ def construct_from_wall(wall: Wall) -> Construct:
156
+ """Project an authored Wall into a Construct. `def_value` rides Wall.def_value
157
+ (None on legacy walls with no authored DEF)."""
158
+ return Construct(
159
+ obj_id=wall.id, kind="wall",
160
+ segment=wall.segment, height_m=wall.height_m,
161
+ blocks_los=wall.blocks_los, blocks_movement=wall.blocks_movement,
162
+ permeability="impermeable" if wall.blocks_movement else "porous",
163
+ cover_level=wall.cover_level,
164
+ def_value=wall.def_value, ed_value=wall.ed_value, resistant=wall.resistant,
165
+ body=wall.body, part_of=wall.part_of,
166
+ )
167
+
168
+
169
+ def construct_from_hazard(hazard: Hazard) -> Construct:
170
+ """Project a legacy authored Hazard into a hazard_zone Construct (porous,
171
+ physical-DEF-gated damage or a status). Suffocation hazards are authored as
172
+ Constructs directly by the driver, not via the legacy Hazard model."""
173
+ eff = hazard.effect
174
+ if not eff.damage_dice and not eff.status_inflicted:
175
+ raise ValueError(
176
+ f"Hazard {hazard.id!r}: HazardEffect has neither damage_dice nor "
177
+ "status_inflicted — nothing to project into a Construct effect"
178
+ )
179
+ kind: EffectKind = "damage" if eff.damage_dice else "status"
180
+ return Construct(
181
+ obj_id=hazard.id, kind="hazard_zone",
182
+ polygon_xy=list(hazard.polygon_xy), elevation_range_m=hazard.elevation_range_m,
183
+ permeability="porous",
184
+ effect=ConstructEffect(
185
+ kind=kind,
186
+ gating="physical_def" if kind == "damage" else "none",
187
+ trigger=hazard.trigger,
188
+ damage_dice=eff.damage_dice, damage_type=eff.damage_type,
189
+ status_inflicted=eff.status_inflicted,
190
+ ),
191
+ )
192
+
193
+
194
+ def constructs_in(scene, damage: Mapping[str, int] | None = None) -> list[Construct]:
195
+ """Everything in this scene that an attack can be aimed AT.
196
+
197
+ The authored constructs, plus every authored `Wall` projected through
198
+ `construct_from_wall`. THE PROJECTION EXISTED AND NOTHING CALLED IT:
199
+ `attack_construct` iterates constructs, so before this the O.K.
200
+ Corral's buildings, barrels and packing crates were not in the fight
201
+ at all, and `smash_cover` --- a tactic whose whole purpose is
202
+ destroying what somebody is hiding behind --- had nothing to smash.
203
+
204
+ Indestructible walls are left out. `def_value is None` is this
205
+ engine's word for "legacy / indestructible", and a target nothing can
206
+ hurt is an offer that wastes a Phase.
207
+
208
+ Walls stay in `scene.walls` as well: movement and line of sight read
209
+ them there, and this is a second VIEW of the same geometry rather than
210
+ a second copy of it.
211
+
212
+ `damage` is BODY already lost, by `obj_id`. A place does not know what
213
+ happened to it --- the fight that did the damage keeps that ledger
214
+ (kirby-combat folds it from its event log) and hands the totals in.
215
+ Without it these are the constructs as authored.
216
+ """
217
+ out = list(getattr(scene, "constructs", None) or [])
218
+ seen = {getattr(c, "obj_id", None) for c in out}
219
+ # FURNISHINGS ARE THINGS YOU CAN SHOOT. A wagon nobody can hit is a
220
+ # wagon nobody can shoot the cover out from under, which is precisely
221
+ # what `smash_cover` exists to do. Their durability comes from 6E2
222
+ # p.173's Objects Table via the material they name, so nobody types a
223
+ # BODY into a scene file by hand.
224
+ for f in (getattr(scene, "furnishings", None) or []):
225
+ if f.id in seen:
226
+ continue
227
+ seen.add(f.id)
228
+ out.append(Construct(
229
+ obj_id=f.id, kind="wall",
230
+ polygon_xy=list(f.polygon_xy),
231
+ elevation_range_m=(0.0, f.height_m),
232
+ body=f.body_value, def_value=f.pd_value,
233
+ ed_value=f.ed_value,
234
+ blocks_los=f.blocks_los,
235
+ blocks_movement=f.blocks_movement,
236
+ # A flag that stops at the projection is the same defect one
237
+ # layer down -- see `Furnishing.portable`.
238
+ portable=f.portable,
239
+ ))
240
+ for wall in (getattr(scene, "walls", None) or []):
241
+ if getattr(wall, "def_value", None) is None:
242
+ continue
243
+ if wall.id in seen:
244
+ continue
245
+ # A FURNISHING'S EDGES ARE THAT FURNISHING, which is already in
246
+ # this list. Without this a wagon is FIVE targets --- itself and
247
+ # its four sides, each a separate offer with the same DEF and
248
+ # BODY. Measured on the long street: 30 constructs from 5
249
+ # furnishings and 25 walls, 25 of them five objects repeated, and
250
+ # a menu where `attack_construct` and `rapid_fire` were about
251
+ # nine offers in ten.
252
+ if hasattr(scene, "is_furnishing_edge") and scene.is_furnishing_edge(wall):
253
+ continue
254
+ out.append(construct_from_wall(wall))
255
+
256
+ # HYDRATION. Without a damage ledger these are the buildings as
257
+ # authored, which is what every caller that has no fight wants.
258
+ if damage is not None:
259
+ hydrated = []
260
+ for c in out:
261
+ if c.body is None:
262
+ hydrated.append(c)
263
+ continue
264
+ left = max(0, c.body - int(damage.get(c.obj_id, 0)))
265
+ # RUBBLE IS NOT A BUILDING. A destroyed construct used to stay
266
+ # in the scene and be knocked over again --- the Harwood House
267
+ # came down on Tom McLaury, Frank McLaury and Billy Clanton,
268
+ # and then came down on them three more times. Once its BODY
269
+ # is gone it stops being a thing you can shoot, hide behind,
270
+ # or drop on somebody.
271
+ if left <= 0:
272
+ continue
273
+ hydrated.append(replace(c, body=left))
274
+ out = hydrated
275
+ return out