tests/demo/screens/pathfinding_demo.py runs three panels side-by-side:
Panel 1 - A* with selectable heuristic. Keys 1-5 cycle EUCLIDEAN, MANHATTAN,
CHEBYSHEV, DIAGONAL, ZERO. Q/W bump the weight by 0.25 to show
weighted A* behaviour.
Panel 2 - Dijkstra flood from a cursor-controlled root. Arrow keys move the
cursor; the distance field re-renders as a blue gradient.
Panel 3 - Multi-root FLEE: three guard entities flee from a shared set of
threats using an inverted multi-root DijkstraMap, animated one
step per timer tick. T adds a new threat; R resets.
Exercises the new surface: mcrfpy.Heuristic, Grid.find_path(heuristic=,
weight=), Grid.get_dijkstra_map(roots=...), DijkstraMap.invert(), and
DijkstraMap.descent_step().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EntityBehavior no longer holds a direct DijkstraMap reference. A new
PathProvider interface has three concrete implementations:
- DijkstraProvider: steps along a (possibly inverted) DijkstraMap. SEEK
descends a normal map toward roots; FLEE descends an inverted map away
from threats.
- AStarProvider: follows a pre-computed AStarPath step-by-step.
- TargetProvider: takes a single (x, y) target and picks the Chebyshev
neighbor closest to it each turn.
Entity.set_behavior() gains a pathfinder= kwarg accepting any of the above
(DijkstraMap, AStarPath, or (x, y) tuple). The old executeSeek/executeFlee
helpers collapse into a single executeProviderStep() that delegates to the
provider.
EntityBehavior.h forward-declares PathProvider so the header stays light.
EntityBehavior::reset() moves out of line to avoid pulling PathProvider
into the header.
New tests: tests/regression/issue_315_path_provider_test.py covers all three
providers driving SEEK, FLEE via inverted DijkstraMap, mid-run pathfinder
swap, and invalid-argument handling. grid_step_bench baseline refreshed
against the new provider dispatch path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A (Python surface):
- New mcrfpy.Heuristic IntEnum: EUCLIDEAN, MANHATTAN, CHEBYSHEV, DIAGONAL, ZERO
- Grid.find_path() accepts heuristic= and weight= kwargs (weighted A*)
- Grid.get_dijkstra_map() accepts roots= (list of positions or DiscreteMap mask)
Phase B (FLEE primitives):
- DijkstraMap.invert() returns a new map with inverted distance field
- DijkstraMap.descent_step(pos) returns steepest-descent neighbor or None
DijkstraMap internally switched from the C++ TCODDijkstra wrapper to the C API
(TCOD_dijkstra_*) because multi-root compute and invert/get_descent are not
exposed on the wrapper. Single-root Dijkstra cache is preserved for backward
compatibility; multi-root and mask paths bypass the cache since cache keys
would be ill-defined.
New tests: heuristic_enum_test, find_path_heuristic_test, multi_root_dijkstra_test,
dijkstra_flee_test. Baseline JSONs for dijkstra_bench and gridview_render_bench
refreshed against the new implementation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 6 benchmark scripts in tests/benchmarks/ covering all 5 scenarios from
Kanboard #37, plus a shared baseline helper:
grid_step_bench.py 100 ent / 100x100 grid / 1000 grid.step() rounds
mix of IDLE/NOISE8/SEEK/FLEE behaviors
fov_opt_bench.py 100 ent / 1000x1000 grid; entity.update_visibility()
(with DiscreteMap perspective writeback) vs bare
grid.compute_fov() (no writeback) across FOV
algorithms BASIC/SHADOW/SYMMETRIC_SHADOWCAST and
radii 8/16/32
spatial_hash_bench.py entities_in_radius() at radii (1,5,10,50) x
entity counts (100,1k,10k); compares against
naive O(n) baseline with hit-count validation
pathfinding_bench.py A* across grid sizes/densities/heuristics/weights,
plus with-vs-without `collide=` collision-label
comparison (0/10/100 blockers on 100x100)
gridview_render_bench.py 1/2/4 GridViews on shared grid; uses
automation.screenshot() to force real renders in
headless mode (mcrfpy.step alone is render-stubbed)
dijkstra_bench.py single-root, multi-root, mask, invert, descent
_baseline.py writes baseline JSON to baseline/phase5_2/
All scripts emit JSON to stdout and write a baseline copy under
tests/benchmarks/baseline/phase5_2/ for regression comparison. All run
headless; pure time.perf_counter() timing for compute benches, screenshot
wall-time for the render bench (start/end_benchmark would only capture the
no-op headless game loop, so direct timing is used).
Notable findings captured in baselines:
- spatial hash: 5x to >300x speedup over naive O(n), hits validated identical
- update_visibility: ~25-37 ms/entity perspective writeback overhead on
1000x1000 grid (full-grid demote+promote loop in UIEntity::updateVisibility)
dominates over the actual TCOD FOV cost (~3-24 ms). Worth a follow-up issue
for sparse perspective updating.
- gridview render: per-view cost scales near-linearly down (~78ms total for
1, 2, or 4 views) -- the multi-view system shares state efficiently.
Refs Kanboard #37.
tests/integration/grid_entity_e2e_test.py exercises the full
Grid + GridView + EntityBehavior stack over 100 turns with four
simultaneous entities on a 20x20 walled grid:
Player turn_order=0 manual placement, excluded from step()
Guard PATROL waypoints + target_label="player" + sight_radius=5
step() callback switches to SEEK on TARGET trigger
NPC NOISE8 random 8-directional wander
Trap SLEEP turns=10, DONE callback fires then reverts to IDLE
Player is placed near the (15,15) waypoint so the Guard both
patrols (visits >=1 waypoint) and engages SEEK once in sight.
Verifies trigger count, DONE step index, behavior_type reversion,
and no crashes / Python exceptions over the full 100-turn run.
Kanboard card 36.
Per-entity FOV memory moves from std::vector<UIGridPointState> (two-bool
visible/discovered pairs) to a 3-state DiscreteMap (0=UNKNOWN, 1=DISCOVERED,
2=VISIBLE), exposed as entity.perspective_map. The invariant
visible-subset-of-discovered becomes structural (single value per cell), and
the map is a live, serializable, first-class object rather than an implicit
internal array.
Changes:
- New DiscreteMap C++ class with shared ownership; PyDiscreteMapObject now
holds shared_ptr<DiscreteMap>. UIEntity holds the same shared_ptr.
- New mcrfpy.Perspective IntEnum (UNKNOWN/DISCOVERED/VISIBLE), modelled on
PyInputState.
- entity.perspective_map: lazy-allocated on first access with a grid;
setter validates size against grid and raises ValueError on mismatch;
None clears (next access lazy-reallocates fresh).
- updateVisibility() now demotes 2->1 then promotes visible cells to 2.
- entity.at(x, y) returns grid.at(x, y) when VISIBLE, else None.
- Fog-of-war rendering in UIGridView and UIGrid reads the 3-state map.
- Removed: UIEntity::gridstate, ensureGridstate(), entity.gridstate getter,
UIGridPointState struct + PyUIGridPointStateType.
- Obsolete tests deleted (test_gridpointstate_point,
issue_265_gridpointstate_dangle); 4 new tests cover lazy allocation,
identity, serialization round-trip, size validation, and the
visible-subset-of-discovered invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DijkstraMap.distance, .path_from, and .step_from forwarded their
coordinate arguments straight into TCOD's dijkstra routines, which
assert and abort() the entire process on out-of-bounds input. No
recoverable Python exception; the whole interpreter dies.
Validate at the binding layer. Out-of-range coordinates raise
IndexError with a message identifying the map dimensions.
Fuzz corpus crash
tests/fuzz/crashes/pathfinding_behavior-crash-b7ea442fd31774b9b16c8ae99c728f609c8c25d8
now runs cleanly.
closes#311
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compute_fov binding parsed its `algorithm` argument as a raw int and
cast it directly to TCOD_fov_algorithm_t. Out-of-range values (e.g. -49
reinterpreted as 4294967247) triggered UBSan's "invalid enum load" deep
in GridData::computeFOV.
PyFOV::from_arg already does the right validation for every other
algorithm entry point: accepts the FOV IntEnum, ints in
[0, NB_FOV_ALGORITHMS), or None (default BASIC); raises ValueError
otherwise. Route the binding through it.
Fuzz corpus crash
tests/fuzz/crashes/fov-crash-d5da064d802ae2b5691c520907cd692d04de8bb2
now runs cleanly.
closes#310
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `outline` and `font_size` property setters (and the matching kwargs
in __init__) passed a raw float straight into SFML's setOutlineThickness
and setCharacterSize. setCharacterSize takes unsigned int, so any
negative or out-of-range float produced undefined behavior — UBSan's
"value outside the representable range" report.
Clamp before the cast. Negative outline is also nonsensical, so it's
clamped to 0 for consistency (not UB, but wrong).
Fuzz corpus crash
tests/fuzz/crashes/property_types-crash-1f7141d732736d04b99d20261abd766194246ea6
now runs cleanly.
closes#309
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fuzzes grid.get_dijkstra_map with random roots/diagonal_cost/collide,
DijkstraMap.distance/path_from/step_from/to_heightmap queries, and
grid.step() with entity behavior callbacks that mutate the entity
list mid-iteration (adjacent to #273).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fuzzes procgen through its standardized data-container interface: direct
HeightMap and DiscreteMap operations (scalar, binary, bitwise, subscript)
plus one-directional conversions NoiseSource.sample -> HeightMap,
BSP.to_heightmap, DiscreteMap.from_heightmap, and dm.to_heightmap.
Treating HM/DM as the unified surface covers every procgen system
without fuzzing each individually.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Targets #269 (PythonObjectCache race), #270 (GridLayer dangling parent),
#275 (UIEntity missing tp_dealloc), #277 (GridChunk dangling parent).
Exercises timer/animation callbacks that mutate scene and drawable
lifetimes across firing boundaries, including scene swap mid-callback
and closure captures that can survive past their target's lifetime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Targets #267 (PyObject_GetAttrString reference leaks), #268 (sfVector2f
NULL deref), #272 (UniformCollection weak_ptr). Exercises every exposed
property on Frame/Caption/Sprite/Grid/Entity/TileLayer/ColorLayer/Color/
Vector with both correct-type and deliberately-wrong-type values, plus
hot-loop repeated GetAttrString to stress refcount sites.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Random compute_fov/is_in_fov exercises with varying origin, radius,
light_walls, algorithm params including out-of-bounds origins and
extreme radii. Toggles grid.at(x,y).transparent between computes to
stress stale fov-state bugs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Targets #258-#263 (gridstate overflow on entity transfer between
differently-sized grids), #273 (entity.die during iteration), #274
(spatial hash on set_grid). Dispatches 13 operations driven by
ByteStream from fuzz_common.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pivots away from atheris (which lacks Python 3.14 support) to a single
libFuzzer-linked executable that embeds CPython, registers mcrfpy, and
dispatches each iteration to a Python fuzz_one_input(data: bytes) function
loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var.
libFuzzer instruments the C++ engine code where all #258-#278 bugs live;
Python drives the fuzzing logic via an in-house ByteStream replacement
for atheris.FuzzedDataProvider. Python-level exceptions are caught; only
ASan/UBSan signal real bugs.
CMake
- MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp
plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address,
undefined. Asset+lib post-build copy added so the embedded interpreter
finds its stdlib and default_font/default_texture load.
Makefile
- fuzz-build builds only mcrfpy_fuzz (fast iterate)
- fuzz loops over six targets setting MCRF_FUZZ_TARGET for each
- fuzz-long TARGET=x SECONDS=n for deep manual runs
- fuzz-repro TARGET=x CRASH=path for crash reproduction
- Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define
tests/fuzz
- fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target,
resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes,
calls target, swallows Python errors.
- fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS
- Six target stubs (grid_entity, property_types, anim_timer_scene,
maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up
- README with build/run/triage instructions
Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz,
make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each,
667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus
input cleanly. No crashes from the stubs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Entities can now specify per-tile sprite indices via the sprite_grid
property. When set, each tile in a multi-tile entity renders its own
sprite from the texture atlas instead of the single entity sprite.
API:
entity.tile_size = (3, 2)
entity.sprite_grid = [[10, 11, 12], [20, 21, 22]]
entity.sprite_grid = None # revert to single sprite
Accepts nested lists, flat lists, or tuples. Use -1 for empty tiles.
Dimensions must match tile_width x tile_height.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entities can now span multiple grid cells via tile_width and tile_height
properties (default 1x1). Frustum culling accounts for entity footprint,
and spatial hash queries return multi-tile entities for all covered cells.
API: entity.tile_size = (2, 2) or entity.tile_width = 2; entity.tile_height = 3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Textures can now specify display_size and display_origin to crop sprite
rendering to a sub-region within each atlas cell. This supports texture
atlases where content doesn't fill the entire cell (e.g., 16x24 sprites
centered in 32x32 cells).
API: Texture("sprites.png", 32, 32, display_size=(16, 24), display_origin=(8, 4))
Properties: display_width, display_height, display_offset_x, display_offset_y
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers #268 (sfVector2f_to_PyObject NULL propagation) and #272
(UniformCollection weak_ptr validity check). Combined with existing
tests for #258-#278, this completes regression coverage for the
full memory safety audit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers two previously untested bug families from the memory safety audit:
- #265: GridPointState references after entity grid transfer
- #267, #275: Reference count leaks in collection/property access loops
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
to_bytes() returns raw uint8 cell data as Python bytes object.
from_bytes(data, size, enum=None) is a classmethod that constructs
a new DiscreteMap from serialized data with dimension validation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
UIFrame, UICaption, and UISprite now accept "pos" as an alias for "position"
in the animation property system. UICaption and UISprite gain Vector2f
setProperty/getProperty overrides enabling animate("pos", (x, y), duration).
Color compound animation (fill_color, outline_color) was already supported.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verifies that die() during grid.entities iteration raises RuntimeError
(iterator invalidation protection), that the safe collect-then-die
pattern works, and that die() properly removes from spatial hash.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When transferring an entity to a new grid via entity.grid = new_grid,
the entity was removed from the old grid's spatial hash but never
inserted into the new one. This made it invisible to spatial queries
on the destination grid.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
UIEntity::setProperty() now calls spatial_hash.update() when draw_x/draw_y
change, matching the Python property setter behavior. Added
enable_shared_from_this<UIEntity> to support shared_from_this() in the
setProperty path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GridLayer, UIGridPoint, and GridChunk each stored a raw GridData* that
could dangle if the grid was destroyed while external shared_ptrs
(e.g. Python layer wrappers) still referenced child objects. The
destructor now nulls all parent_grid pointers before cleanup. All
usage sites already had null guards, so this completes the fix.
These were the last three unfixed bugs from the memory safety audit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Breaking API change: removes 4 camelCase function aliases from the mcrfpy
module. The snake_case equivalents (set_scale, find_all, get_metrics,
set_dev_console) remain and are the canonical API going forward.
- Removed setScale, findAll, getMetrics, setDevConsole from mcrfpyMethods[]
- Updated game scripts to use snake_case names
- Updated test scripts to use snake_case names
- Removed camelCase entries from type stubs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes a systemic bug where Python tp_getset property setters bypassed the
render cache dirty flag system (#144). The animation/C++ setProperty() path
had correct dirty propagation, but direct Python property assignments
(e.g. frame.x = 50, caption.text = "Hello") did not invalidate the parent
Frame's render cache when clip_children or cache_subtree was enabled.
Changes by file:
- UIDrawable.cpp: Add markCompositeDirty() to set_float_member (x/y),
set_pos, set_grid_pos; add markDirty() for w/h resize
- UICaption.cpp: Add markDirty() to set_text, set_color_member,
set_float_member (outline/font_size); markCompositeDirty() for position
- UICollection.cpp: Add markContentDirty() on owner in append, remove,
pop, insert, extend, setitem, and slice assignment/deletion
- UISprite.cpp: Add markDirty() to scale/sprite_index/texture setters;
markCompositeDirty() to position setters
- UICircle.cpp: Add markDirty() to radius/fill_color/outline_color/outline;
markCompositeDirty() to center setter
- UILine.cpp: Add markDirty() to start/end/color/thickness setters
- UIArc.cpp: Add markDirty() to radius/angles/color/thickness setters;
markCompositeDirty() to center setter
- UIGrid.cpp: Add markDirty() to center/zoom/camera_rotation/fill_color/
size/perspective/fov setters
Closes#288, closes#289, closes#290, closes#291
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removed custom __eq__/__ne__ that allowed comparing enums to legacy string
names (e.g., Key.ESCAPE == "Escape"). Removed _legacy_names dicts and
to_legacy_string() functions. Kept from_legacy_string() in PyKey.cpp as
it's used by C++ event dispatch. Updated ~50 Python test/demo/cookbook
files to use enum members instead of string comparisons. Also updates
grid.position -> grid.pos in files that had both types of changes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Grid.position was a redundant alias for Grid.pos. Removed get_position/
set_position functions, getsetters entry, and setProperty/getProperty/
hasProperty branches. Updated all tests to use grid.pos.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
sprite_number was a legacy alias for sprite_index. All code should use
sprite_index directly. Removed from getsetters, setProperty/getProperty/
hasProperty in UISprite and UIEntity, animation property handling, and
type stubs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review of session 1b14b941 found two issues:
- Exported type count was 44 in audit doc but array has 46 entries
(EntityCollection3DIterType and one other were not counted)
- No regression test existed for the Color.__eq__/__ne__ fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add `collide` kwarg to Grid.find_path() and Grid.get_dijkstra_map() that
treats entities bearing a given label as impassable obstacles via
mark-and-restore on the TCOD walkability map. Dijkstra cache key now
includes collide label for separate caching. Add Entity.find_path()
convenience method that delegates to the grid.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
frame_children_mutation_test: validates remove(), property mutation via
stored refs, fill_color persistence, iteration mutation, pop(), and
while-loop clearing — with visual screenshot verification.
parent_none_removal_test: validates that setting .parent = None removes
children from Frame.children and scene.children, Entity.grid = None
removal, and Grid overlay children removal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Generates a network of bezier-curved dirt paths connecting POIs placed
via grid-distributed random selection. Uses minimum spanning tree with
extra fork edges, noise-offset control points for organic curves, and
the "pathways" edge-type Wang set for directional path tiles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Grid.__init__() now auto-creates a GridView that shares the Grid's
data via aliasing shared_ptr. This enables the Grid/GridView split:
- PyUIGridObject gains a `view` member (shared_ptr<UIGridView>)
- Grid.view property exposes the auto-created GridView (read-only)
- Rendering property setters (center_x/y, zoom, camera_rotation, x, y,
w, h) sync changes to the view automatically
- Grid still works as UIDrawable in scenes (no substitution) — backward
compatible with all existing code and subclasses
- GridView.grid returns the original Grid with identity preservation
- Explicit GridViews (created by user) are independent of Grid's own
rendering properties
Addresses #252. All 260 tests pass, no breaking changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GridView is a new UIDrawable that renders a GridData object independently.
Multiple GridViews can reference the same Grid for split-screen, minimap,
or different camera/zoom perspectives on shared grid state.
- New files: UIGridView.h/cpp with full rendering pipeline (copied from
UIGrid::render, adapted to use grid_data pointer)
- Add UIGRIDVIEW to PyObjectsEnum
- Add UIGRIDVIEW cases to all switch(derived_type()) sites:
Animation.cpp, UICollection.cpp, UIDrawable.cpp, McRFPy_API.cpp,
ImGuiSceneExplorer.cpp
- Python type: mcrfpy.GridView(grid=, pos=, size=, zoom=, fill_color=)
- Properties: grid, center, zoom, fill_color, texture
- Register type with metaclass for callback support
All 258 existing tests pass. New GridView test suite added.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix Entity3D self-reference cycle: replace raw `self` pointer with
`pyobject` strong-ref pattern matching UIEntity (closes#266)
- TileLayer inherits Grid texture when none set, in all three attachment
paths: constructor, add_layer(), and .grid property (closes#254)
- Add SpatialHash::queryCell() for O(1) entity-at-cell lookup; fix
missing spatial_hash.insert() in Entity.__init__ grid= kwarg path;
use queryCell in GridPoint.entities (closes#253)
- Add FOV dirty flag and parameter cache to skip redundant computeFOV
calls when map unchanged and params match (closes#292)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Phase 3 fix for #266 removed UIEntity::self which prevented
tp_dealloc from ever running. However, this also allowed Python
subclass wrappers (GameEntity, ZoneExit, etc.) to be GC'd while
the C++ entity lived on in a grid. Later access via grid.entities
returned a base Entity wrapper, losing all subclass methods.
Fix: Add UIEntity::pyobject field that holds a strong reference to
the Python wrapper. Set in init(), cleared when the entity leaves
a grid (die(), set_grid(None), collection removal). This keeps
subclass identity alive while in a grid, but allows proper GC when
the entity is removed. Added releasePyIdentity() helper called at
all grid exit points.
Regression test exercises Liber Noster patterns: subclass hierarchy,
isinstance() checks, combat mixins, tooltip/send methods, GC
survival, die(), pop(), remove(), and stress test with 20 entities.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GridPoint and GridPointState Python objects now store (grid, x, y)
coordinates instead of raw C++ pointers. Data addresses are computed
on each property access, preventing dangling pointers after vector
resizes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>