diff --git a/ROADMAP.md b/ROADMAP.md index b468fb9..a91c226 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,12 +57,12 @@ The active tier1 queue is empty. The last three findings (#309 Caption float→u - **Phase 5.3** -- documentation regenerated; `tools/generate_stubs_v2.py` rewritten as introspection-based so it can no longer drift from the C++ source. ### Recently Shipped (June 2026) +- **#316** -- Sparse (windowed) perspective writeback in `UIEntity::updateVisibility`. The demote+promote passes are now clipped to an AABB sized to `fov_radius` (with a `prev_fov` window cache so a moving entity leaves no trailing "ghost vision"), replacing two full-`W*H` walks per entity. The Phase 5.2 benchmark's flat ~25-36 ms/entity writeback overhead on a 1000x1000 grid collapses to single-digit microseconds (384x-6577x on the cheap algorithms; lost in timing noise on the rest). Adversarial verify caught a regression the happy-path test missed -- externally-assigned maps (the documented `from_bytes` load/resume path) need a one-shot full demote (`perspective_full_demote_pending`) since `prev_fov` only bounds engine-promoted cells; fixed and locked with a 7-section regression test. - **#313** -- `UIEntity::grid` migrated from `shared_ptr` to `shared_ptr` (post-#252 refactor cleanup), adding a new public `entity.texture` read/write property. Merged to master. - **#314** -- API audit follow-through complete. (1) Snapshot lock: a public API-surface regression test (`tests/unit/api_surface_snapshot_test.py`) enshrines the frozen contract. (2) **F15**: all 289 raw docstring slots across the 20 frozen binding files converted to `MCRF_*` macros (frozen surface 100% compliant), driven by two one-agent-per-file workflows with build/doc gates and an adversarial signature-accuracy verify pass. Property types now resolve to real types (not `Any`) and read-only flags are correct. (3) A strict frozen-docstring gate (`tools/check_frozen_docstrings.sh`, wired into `generate_all_docs.sh`) locks it against regression. Breaking-change findings (F1/F4/F6/F11/F13) closed earlier; F7/F8/F10 deferred as non-1.0. Code-level bugs surfaced by the verify pass filed as #317/#318/#319. ### Active Follow-Ups - **#312** Extend fuzz coverage to remaining public API surface -- **#316** Sparse perspective writeback in `UIEntity::updateVisibility` (Phase 5.2 finding: full-grid demote+promote dominates over TCOD FOV cost) - **#317/#318/#319** Minor code bugs found during the #314 verify pass (automation.scroll x ignored; GridView.texture unimplemented; Entity.visible_entities(radius=None) raises) ### Other Post-7DRL Priorities @@ -115,9 +115,9 @@ Rather than inverting the architecture to make McRogueFace a pip-installable pac ## Open Issues by Area -27 open issues across the tracker (#314 closes on merge of this branch). Key groupings: +26 open issues across the tracker (#316 closes on merge of this branch). Key groupings: -- **Recent follow-ups** (#312, #316) -- Fuzz coverage extension, sparse perspective writeback +- **Recent follow-ups** (#312) -- Fuzz coverage extension - **Verify-pass code bugs** (#317, #318, #319) -- automation.scroll x ignored, GridView.texture unimplemented, visible_entities(radius=None) raises - **7DRL 2026 carry-over** (#248) -- Crypt of Sokoban remaster, superseded by the 7DRL 2026 entry but still relevant as a demo - **Tooling / infrastructure** (#282, #255) -- Modern Clang for TSan/fuzzing, performance profiling diff --git a/docs/API_REFERENCE_DYNAMIC.md b/docs/API_REFERENCE_DYNAMIC.md index 63833ca..00b6d35 100644 --- a/docs/API_REFERENCE_DYNAMIC.md +++ b/docs/API_REFERENCE_DYNAMIC.md @@ -1,6 +1,6 @@ # McRogueFace API Reference -*Generated on 2026-06-21 06:42:31* +*Generated on 2026-06-21 09:39:04* *This documentation was dynamically generated from the compiled module.* @@ -1823,7 +1823,7 @@ Attributes: - `move_speed`: Animation duration for behavior movement in seconds (float). 0 = instant. Default: 0.15. - `name`: Entity name for lookup (str). - `opacity`: Render opacity (float). 0.0 = fully transparent, 1.0 = fully opaque. -- `perspective_map`: Per-entity FOV memory (DiscreteMap). 3-state values per cell: 0=unknown, 1=discovered, 2=visible. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. Assigning a DiscreteMap replaces the entity's memory; size must match the grid or ValueError is raised. Assign None to clear. +- `perspective_map`: Per-entity FOV memory (DiscreteMap). 3-state values per cell: 0=unknown, 1=discovered, 2=visible. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. Assigning a DiscreteMap replaces the entity's memory (e.g. loading a saved perspective via from_bytes); size must match the grid or ValueError is raised, and the next updateVisibility() demotes any loaded visible cells to discovered before recomputing FOV. Assign None to clear. Note: updateVisibility() only auto-demotes visible cells the engine itself promoted; if you write 2 (visible) into the live map by hand at a cell outside the entity's current FOV, it will not be auto-demoted -- use 1 (discovered) to reveal remembered cells, or assign a whole map to set arbitrary state. - `pos`: Pixel position relative to grid (Vector). Computed as draw_pos * tile_size. Requires entity to be attached to a grid. - `shader`: GPU shader for visual effects (Shader or None). Set to None to disable shader rendering. - `sight_radius`: FOV radius for TARGET trigger (int). Default: 10. diff --git a/docs/api_reference_dynamic.html b/docs/api_reference_dynamic.html index 1a5482a..f511245 100644 --- a/docs/api_reference_dynamic.html +++ b/docs/api_reference_dynamic.html @@ -108,7 +108,7 @@

McRogueFace API Reference

-

Generated on 2026-06-21 06:42:31

+

Generated on 2026-06-21 09:39:04

This documentation was dynamically generated from the compiled module.

@@ -1979,7 +1979,7 @@ Attributes:
  • move_speed: Animation duration for behavior movement in seconds (float). 0 = instant. Default: 0.15.
  • name: Entity name for lookup (str).
  • opacity: Render opacity (float). 0.0 = fully transparent, 1.0 = fully opaque.
  • -
  • perspective_map: Per-entity FOV memory (DiscreteMap). 3-state values per cell: 0=unknown, 1=discovered, 2=visible. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. Assigning a DiscreteMap replaces the entity's memory; size must match the grid or ValueError is raised. Assign None to clear.
  • +
  • perspective_map: Per-entity FOV memory (DiscreteMap). 3-state values per cell: 0=unknown, 1=discovered, 2=visible. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. Assigning a DiscreteMap replaces the entity's memory (e.g. loading a saved perspective via from_bytes); size must match the grid or ValueError is raised, and the next updateVisibility() demotes any loaded visible cells to discovered before recomputing FOV. Assign None to clear. Note: updateVisibility() only auto-demotes visible cells the engine itself promoted; if you write 2 (visible) into the live map by hand at a cell outside the entity's current FOV, it will not be auto-demoted -- use 1 (discovered) to reveal remembered cells, or assign a whole map to set arbitrary state.
  • pos: Pixel position relative to grid (Vector). Computed as draw_pos * tile_size. Requires entity to be attached to a grid.
  • shader: GPU shader for visual effects (Shader or None). Set to None to disable shader rendering.
  • sight_radius: FOV radius for TARGET trigger (int). Default: 10.
  • diff --git a/docs/mcrfpy.3 b/docs/mcrfpy.3 index 248bd4a..e390525 100644 --- a/docs/mcrfpy.3 +++ b/docs/mcrfpy.3 @@ -14,11 +14,11 @@ . ftr VB CB . ftr VBI CBI .\} -.TH "MCRFPY" "3" "2026-06-21" "McRogueFace 0.2.7-prerelease-7drl2026-104-g5725a4f" "" +.TH "MCRFPY" "3" "2026-06-21" "McRogueFace 0.2.7-prerelease-7drl2026-107-g3f39ee0" "" .hy .SH McRogueFace API Reference .PP -\f[I]Generated on 2026-06-21 06:42:31\f[R] +\f[I]Generated on 2026-06-21 09:39:04\f[R] .PP \f[I]This documentation was dynamically generated from the compiled module.\f[R] @@ -1999,9 +1999,16 @@ Default: 0.15. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. -Assigning a DiscreteMap replaces the entity\[cq]s memory; size must -match the grid or ValueError is raised. +Assigning a DiscreteMap replaces the entity\[cq]s memory (e.g.\ loading +a saved perspective via from_bytes); size must match the grid or +ValueError is raised, and the next updateVisibility() demotes any loaded +visible cells to discovered before recomputing FOV. Assign None to clear. +Note: updateVisibility() only auto-demotes visible cells the engine +itself promoted; if you write 2 (visible) into the live map by hand at a +cell outside the entity\[cq]s current FOV, it will not be auto-demoted +\[en] use 1 (discovered) to reveal remembered cells, or assign a whole +map to set arbitrary state. - \f[V]pos\f[R]: Pixel position relative to grid (Vector). Computed as draw_pos * tile_size. Requires entity to be attached to a grid. diff --git a/src/DiscreteMap.cpp b/src/DiscreteMap.cpp index 949c0c9..4d9a445 100644 --- a/src/DiscreteMap.cpp +++ b/src/DiscreteMap.cpp @@ -25,3 +25,14 @@ void DiscreteMap::demoteVisible() if (values_[i] == 2) values_[i] = 1; } } + +void DiscreteMap::demoteVisibleRect(int x0, int y0, int x1, int y1) +{ + // Caller guarantees clamped, half-open bounds (see header). + for (int gy = y0; gy < y1; ++gy) { + uint8_t* row = values_ + static_cast(gy) * w_; + for (int gx = x0; gx < x1; ++gx) { + if (row[gx] == 2) row[gx] = 1; + } + } +} diff --git a/src/DiscreteMap.h b/src/DiscreteMap.h index d52d200..9297b23 100644 --- a/src/DiscreteMap.h +++ b/src/DiscreteMap.h @@ -28,6 +28,14 @@ public: // per-tick demotion of "was visible last tick" to "discovered". void demoteVisible(); + // #316: Windowed variant of demoteVisible(). Demotes cells with value == 2 + // back to 1, restricted to the half-open rectangle [x0,x1) x [y0,y1). + // Callers must clamp the bounds to the map (0 <= x0 <= x1 <= width, + // 0 <= y0 <= y1 <= height); no bounds checking is performed here. Used by + // UIEntity::updateVisibility() to demote only last tick's FOV window + // instead of walking the entire W*H buffer. + void demoteVisibleRect(int x0, int y0, int x1, int y1); + private: int w_; int h_; diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index 53d397c..934b462 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -60,35 +60,83 @@ void UIEntity::updateVisibility() // Lazy-allocate or resize perspective_map if grid dimensions changed. // Dimension mismatch wipes prior state -- the entity's memory has no // identity with a differently-sized grid, so starting fresh is correct. + // On (re)allocation, reset the cached previous-FOV window (#316) so we never + // demote a stale rect against a freshly-sized buffer (would be wrong/OOB). size_t expected = static_cast(grid->grid_w) * grid->grid_h; + bool fresh = false; if (!perspective_map || perspective_map->size() != expected) { perspective_map = std::make_shared(grid->grid_w, grid->grid_h, 0); + fresh = true; + prev_fov_x0 = prev_fov_y0 = prev_fov_x1 = prev_fov_y1 = 0; + // A fresh buffer is all-zero: nothing to demote, and any pending + // external-assignment demote is moot (the assigned map was discarded). + perspective_full_demote_pending = false; } - // Demote visible (2) -> discovered (1) from prior tick. The invariant - // `visible subset of discovered` is structural in the 3-state model: cells - // currently visible will be re-promoted to 2 below, and cells that - // just left FOV fall to discovered. - perspective_map->demoteVisible(); - // Compute FOV from entity's cell position (#114, #295) int x = cell_position.x; int y = cell_position.y; + int r = grid->fov_radius; + + // #316: Clip both the demote and promote passes to an AABB sized to the FOV + // radius around the entity, instead of walking the whole W*H buffer twice. + // Margins of +/-1 (with half-open +1) cover light_walls lighting a wall one + // cell beyond the radius. r <= 0 means unlimited == full grid (matches TCOD + // radius 0); store the full-grid window so the next tick demotes correctly. + int cx0, cy0, cx1, cy1; + if (r > 0) { + cx0 = std::max(0, x - r - 1); + cy0 = std::max(0, y - r - 1); + cx1 = std::min(grid->grid_w, x + r + 2); + cy1 = std::min(grid->grid_h, y + r + 2); + } else { + cx0 = 0; cy0 = 0; + cx1 = grid->grid_w; cy1 = grid->grid_h; + } + + // Demote visible (2) -> discovered (1) from the LAST tick's promoted window + // (NOT the current one). Demoting the current window would leave cells the + // entity saw last tick -- now outside the new window after a move -- stuck + // at VISIBLE=2 forever (ghost vision trailing the entity). The cells we + // promoted last tick are exactly the only cells that can be 2 right now. + // On a fresh map there is nothing to demote. + // + // EXCEPTION (#316): when a whole map was just assigned externally (e.g. a + // from_bytes load/resume), prev_fov no longer bounds the VISIBLE cells -- the + // assigned map can hold 2s anywhere. Fall back to a single full-buffer demote + // so loaded VISIBLE cells correctly become DISCOVERED before the FOV recompute, + // matching the pre-#316 semantics. This one-shot cost is paid only on the tick + // after an assignment, never on the per-turn movement hot path. + if (fresh) { + // nothing to demote + } else if (perspective_full_demote_pending) { + perspective_map->demoteVisible(); + perspective_full_demote_pending = false; + } else if (prev_fov_x1 > prev_fov_x0 && prev_fov_y1 > prev_fov_y0) { + perspective_map->demoteVisibleRect(prev_fov_x0, prev_fov_y0, + prev_fov_x1, prev_fov_y1); + } // Use grid's configured FOV algorithm and radius grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm); - // Promote visible cells to 2 (VISIBLE). Cells going 0 -> 2 are - // freshly discovered; cells going 1 -> 2 were already discovered. + // Promote visible cells to 2 (VISIBLE), clipped to the current window. + // Cells going 0 -> 2 are freshly discovered; cells going 1 -> 2 were + // already discovered. uint8_t* buf = perspective_map->data(); - for (int gy = 0; gy < grid->grid_h; gy++) { - for (int gx = 0; gx < grid->grid_w; gx++) { + int W = grid->grid_w; + for (int gy = cy0; gy < cy1; ++gy) { + for (int gx = cx0; gx < cx1; ++gx) { if (grid->isInFOV(gx, gy)) { - buf[gy * grid->grid_w + gx] = PyPerspective::VISIBLE; + buf[gy * W + gx] = PyPerspective::VISIBLE; } } } + // Cache this tick's promoted window so the next call demotes exactly it. + prev_fov_x0 = cx0; prev_fov_y0 = cy0; + prev_fov_x1 = cx1; prev_fov_y1 = cy1; + // #113 - Update any ColorLayers bound to this entity via perspective // Get shared_ptr to self for comparison std::shared_ptr self_ptr = nullptr; @@ -457,6 +505,9 @@ int UIEntity::set_perspective_map(PyUIEntityObject* self, PyObject* value, void* if (value == NULL || value == Py_None) { entity->perspective_map.reset(); + // Lazy realloc on next access produces an all-zero buffer (handled by + // the fresh path in updateVisibility), so no pending full demote. + entity->perspective_full_demote_pending = false; return 0; } @@ -489,6 +540,10 @@ int UIEntity::set_perspective_map(PyUIEntityObject* self, PyObject* value, void* } entity->perspective_map = incoming->data; // share ownership + // #316: an externally-assigned map may hold VISIBLE=2 cells anywhere, so the + // cached prev_fov window can no longer bound the demote. Force a one-shot + // full demote on the next updateVisibility() (load/resume correctness). + entity->perspective_full_demote_pending = true; return 0; } @@ -1885,7 +1940,7 @@ PyGetSetDef UIEntity::getsetters[] = { MCRF_PROPERTY(draw_pos, "Fractional tile position for rendering (Vector). Use for smooth animation between grid cells."), (void*)0}, {"perspective_map", (getter)UIEntity::get_perspective_map, (setter)UIEntity::set_perspective_map, - MCRF_PROPERTY(perspective_map, "Per-entity FOV memory (DiscreteMap). 3-state values per cell: 0=unknown, 1=discovered, 2=visible. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. Assigning a DiscreteMap replaces the entity's memory; size must match the grid or ValueError is raised. Assign None to clear."), + MCRF_PROPERTY(perspective_map, "Per-entity FOV memory (DiscreteMap). 3-state values per cell: 0=unknown, 1=discovered, 2=visible. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. Assigning a DiscreteMap replaces the entity's memory (e.g. loading a saved perspective via from_bytes); size must match the grid or ValueError is raised, and the next updateVisibility() demotes any loaded visible cells to discovered before recomputing FOV. Assign None to clear. Note: updateVisibility() only auto-demotes visible cells the engine itself promoted; if you write 2 (visible) into the live map by hand at a cell outside the entity's current FOV, it will not be auto-demoted -- use 1 (discovered) to reveal remembered cells, or assign a whole map to set arbitrary state."), NULL}, {"grid", (getter)UIEntity::get_grid, (setter)UIEntity::set_grid, MCRF_PROPERTY(grid, "Grid this entity belongs to (Grid or None). Assign a Grid to attach the entity, or None to remove it from its current grid."), NULL}, diff --git a/src/UIEntity.h b/src/UIEntity.h index 003cd89..62ec14b 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -78,6 +78,21 @@ public: // updateVisibility() runs with a grid whose dimensions differ from the // current map. See PyPerspective for the Python-side enum. std::shared_ptr perspective_map; + // #316: AABB of the cells promoted to VISIBLE on the previous + // updateVisibility() tick. That rectangle is exactly the set of cells that + // can currently be VISIBLE=2, so it is the region we must demote before the + // next promote pass. Empty when x1 <= x0 (initial value 0,0,0,0 => nothing + // to demote on the first call). Reset to 0 whenever perspective_map is + // (re)allocated so a stale rect is never demoted against a fresh buffer. + int prev_fov_x0 = 0, prev_fov_y0 = 0, prev_fov_x1 = 0, prev_fov_y1 = 0; + // #316: the windowed demote above is only valid while the engine exclusively + // owns perspective_map. When a whole map is assigned externally (e.g. a + // from_bytes load/resume), it may carry VISIBLE=2 cells anywhere, so the + // prev_fov window no longer bounds the set of cells that can be 2. This flag + // forces a single full-buffer demote on the next updateVisibility() so loaded + // VISIBLE cells correctly fall to DISCOVERED before the FOV is recomputed. + // Set by set_perspective_map(); cleared after the one-shot full demote. + bool perspective_full_demote_pending = false; UISprite sprite; sf::Vector2f position; //(x,y) in grid coordinates; float for animation sf::Vector2i cell_position{0, 0}; // #295: integer logical position (decoupled from float position) diff --git a/tests/regression/issue_316_sparse_perspective_test.py b/tests/regression/issue_316_sparse_perspective_test.py new file mode 100644 index 0000000..ca7bd86 --- /dev/null +++ b/tests/regression/issue_316_sparse_perspective_test.py @@ -0,0 +1,479 @@ +""" +Regression test for issue #316 -- Sparse (windowed) perspective writeback in +UIEntity::updateVisibility(). + +The optimization clips both the demote and promote passes to an AABB sized to +fov_radius around the entity, instead of walking the whole W*H buffer. This test +proves the windowed result is byte-for-byte identical to a full-grid reference, +and that the previous-window demote cache prevents "ghost vision" when an entity +moves. + +ASCII-only source (scripts run via --exec use the ASCII codec). +Prints clear PASS/FAIL and sys.exit(0/1). +""" + +import mcrfpy +import sys + +# --- Test grid configuration ------------------------------------------------- +W, H = 60, 60 + +scene = mcrfpy.Scene("issue316") +mcrfpy.current_scene = scene +grid = mcrfpy.Grid(grid_size=(W, H)) +scene.children.append(grid) + +# Make the whole grid walkable + transparent, then carve some interior walls so +# that FOV is actually occluded and light_walls lights boundary walls -- this is +# the strongest check that the +1 AABB margin is wide enough. +for gx in range(W): + for gy in range(H): + gp = grid.at(gx, gy) + gp.walkable = True + gp.transparent = True + +# A few interior wall segments (opaque, blocking). +wall_cells = [] +for gy in range(10, 50): + wall_cells.append((25, gy)) # vertical wall +for gx in range(5, 55): + wall_cells.append((gx, 30)) # horizontal wall +# isolated blocks scattered around +for (bx, by) in [(12, 12), (40, 18), (48, 48), (8, 45), (33, 8), (52, 33)]: + wall_cells.append((bx, by)) + +for (wx, wy) in wall_cells: + gp = grid.at(wx, wy) + gp.transparent = False + # keep walkable True so we can also stand near walls; transparency is what + # matters for FOV. + +# Entity to test with. +entity = mcrfpy.Entity(grid_pos=(W // 2, H // 2)) +grid.entities.append(entity) + +# Algorithms to exercise (names verified against PyFOV.cpp). +ALGOS = [ + ("BASIC", mcrfpy.FOV.BASIC), + ("DIAMOND", mcrfpy.FOV.DIAMOND), + ("SHADOW", mcrfpy.FOV.SHADOW), + ("SYMMETRIC_SHADOWCAST", mcrfpy.FOV.SYMMETRIC_SHADOWCAST), +] + +VISIBLE = 2 +DISCOVERED = 1 +UNKNOWN = 0 + +failures = [] + + +def move_entity(ex, ey): + """Move the entity's logical cell position.""" + entity.grid_pos = (ex, ey) + + +def reference_fov_set(ex, ey, radius, algo): + """Compute the authoritative in-FOV cell set using the SAME parameters + updateVisibility() uses internally (radius, light_walls=True, same algo).""" + grid.compute_fov((ex, ey), radius=radius, light_walls=True, algorithm=algo) + s = set() + for gx in range(W): + for gy in range(H): + if grid.is_in_fov((gx, gy)): + s.add((gx, gy)) + return s + + +def assert_full_equivalence(label, ex, ey, radius, algo): + """Set up grid FOV params, run windowed update_visibility(), and compare the + ENTIRE perspective map against a full-grid reference scan.""" + grid.fov_radius = radius + grid.fov = algo + move_entity(ex, ey) + pm = entity.perspective_map + pm.fill(0) # wipe DISCOVERED history so out-of-FOV must be exactly UNKNOWN + entity.update_visibility() + + expected_fov = reference_fov_set(ex, ey, radius, algo) + + mism = 0 + first_bad = None + for gx in range(W): + for gy in range(H): + got = int(pm[gx, gy]) + exp = VISIBLE if (gx, gy) in expected_fov else UNKNOWN + if got != exp: + mism += 1 + if first_bad is None: + first_bad = (gx, gy, got, exp) + if mism: + failures.append( + "EQUIV FAIL [%s] pos=(%d,%d) r=%d algo=%s: %d mismatched cells; " + "first=%s" % (label, ex, ey, radius, label_algo(algo), mism, first_bad) + ) + return False + return True + + +def label_algo(algo): + for name, a in ALGOS: + if a == algo: + return name + return str(algo) + + +# ---------------------------------------------------------------------------- +# 1. EQUIVALENCE MATRIX: positions x radii x algorithms +# ---------------------------------------------------------------------------- +positions = [ + ("corner_TL", 0, 0), + ("corner_BR", W - 1, H - 1), + ("edge_left_mid", 0, H // 2), + ("edge_top_mid", W // 2, 0), + ("center", W // 2, H // 2), + ("near_wall", 24, 30), # right next to interior wall cross +] +radii = [8, 16, 32] + +equiv_total = 0 +equiv_pass = 0 +for (pname, px, py) in positions: + for radius in radii: + for (aname, algo) in ALGOS: + equiv_total += 1 + if assert_full_equivalence(aname, px, py, radius, algo): + equiv_pass += 1 + +print("Equivalence matrix: %d/%d cases passed" % (equiv_pass, equiv_total)) + + +# ---------------------------------------------------------------------------- +# 2. radius 0 == unlimited == full grid window +# ---------------------------------------------------------------------------- +r0_total = 0 +r0_pass = 0 +for (pname, px, py) in [("center", W // 2, H // 2), ("corner_TL", 0, 0)]: + for (aname, algo) in ALGOS: + r0_total += 1 + if assert_full_equivalence(aname, px, py, 0, algo): + r0_pass += 1 +print("Radius-0 (unlimited) cases: %d/%d passed" % (r0_pass, r0_total)) + + +# ---------------------------------------------------------------------------- +# 3. MOVING TEST (disjoint windows): no ghost vision after a long jump. +# ---------------------------------------------------------------------------- +def current_fov_set(ex, ey, radius, algo): + return reference_fov_set(ex, ey, radius, algo) + + +radius = 8 +algo = mcrfpy.FOV.SHADOW +grid.fov_radius = radius +grid.fov = algo + +pm = entity.perspective_map +pm.fill(0) + +# Place at A, update, record the VISIBLE set. +Ax, Ay = 12, 12 +move_entity(Ax, Ay) +entity.update_visibility() +S1 = set() +for gx in range(W): + for gy in range(H): + if int(pm[gx, gy]) == VISIBLE: + S1.add((gx, gy)) + +# Move to B with chebyshev distance > 2*radius so the windows are disjoint. +Bx, By = 45, 45 +assert max(abs(Bx - Ax), abs(By - Ay)) > 2 * radius, "B not far enough for disjoint test" +move_entity(Bx, By) +entity.update_visibility() + +fov_B = current_fov_set(Bx, By, radius, algo) + +ghost = 0 +ghost_first = None +for (gx, gy) in S1: + if (gx, gy) not in fov_B: + # Cell visible at A but not currently in FOV must be DISCOVERED, NEVER VISIBLE. + if int(pm[gx, gy]) == VISIBLE: + ghost += 1 + if ghost_first is None: + ghost_first = (gx, gy) +if ghost: + failures.append( + "MOVING(disjoint) FAIL: %d cells from A still VISIBLE after moving to B " + "(ghost vision); first=%s" % (ghost, ghost_first) + ) + +# Every currently-in-FOV cell at B must be VISIBLE. +missing_B = 0 +missing_first = None +for (gx, gy) in fov_B: + if int(pm[gx, gy]) != VISIBLE: + missing_B += 1 + if missing_first is None: + missing_first = (gx, gy, int(pm[gx, gy])) +if missing_B: + failures.append( + "MOVING(disjoint) FAIL: %d cells in B's FOV not VISIBLE; first=%s" + % (missing_B, missing_first) + ) + +print("Moving test (disjoint A->B): ghost=%d, missing_at_B=%d" % (ghost, missing_B)) + + +# ---------------------------------------------------------------------------- +# 4. TRAILING-EDGE TEST (overlapping windows): cells that leave FOV after a +# 1-cell move are demoted to DISCOVERED, not stuck at VISIBLE. +# ---------------------------------------------------------------------------- +pm.fill(0) +Cx, Cy = 30, 45 # open area away from walls +move_entity(Cx, Cy) +entity.update_visibility() +fov_C = current_fov_set(Cx, Cy, radius, algo) + +move_entity(Cx + 1, Cy) # small overlapping move +entity.update_visibility() +fov_D = current_fov_set(Cx + 1, Cy, radius, algo) + +trailing_bad = 0 +trailing_first = None +for (gx, gy) in fov_C: + if (gx, gy) not in fov_D: + # left FOV between C and D: must be demoted to DISCOVERED(1), not VISIBLE(2) + if int(pm[gx, gy]) == VISIBLE: + trailing_bad += 1 + if trailing_first is None: + trailing_first = (gx, gy) +if trailing_bad: + failures.append( + "TRAILING-EDGE FAIL: %d cells that left FOV remain VISIBLE; first=%s" + % (trailing_bad, trailing_first) + ) + +# And new cells now in FOV at D must be VISIBLE. +trailing_missing = 0 +for (gx, gy) in fov_D: + if int(pm[gx, gy]) != VISIBLE: + trailing_missing += 1 +if trailing_missing: + failures.append( + "TRAILING-EDGE FAIL: %d cells in D's FOV not VISIBLE" % trailing_missing + ) + +print("Trailing-edge test (1-cell move): stuck_visible=%d, missing=%d" + % (trailing_bad, trailing_missing)) + + +# ---------------------------------------------------------------------------- +# 5. GRID RESIZE: moving entity to a differently-sized grid reallocates the +# perspective map (size() != expected), takes the fresh path (no stale demote, +# no OOB), and yields a correct map of the new size. +# ---------------------------------------------------------------------------- +small_W, small_H = 20, 20 +small_grid = mcrfpy.Grid(grid_size=(small_W, small_H)) +scene.children.append(small_grid) +for gx in range(small_W): + for gy in range(small_H): + gp = small_grid.at(gx, gy) + gp.walkable = True + gp.transparent = True +small_grid.fov_radius = 6 +small_grid.fov = mcrfpy.FOV.BASIC + +resize_ok = True + +# Put a fresh entity on the small grid first. +e2 = mcrfpy.Entity(grid_pos=(5, 5)) +small_grid.entities.append(e2) +e2.update_visibility() +pm_small = e2.perspective_map +if pm_small.size != (small_W, small_H): + failures.append("RESIZE FAIL: small pm.size=%s expected=%s" + % (pm_small.size, (small_W, small_H))) + resize_ok = False + +# Now move the SAME entity object to the big grid (different dimensions). This +# triggers the size mismatch realloc path. Use grid setter via entity.grid. +e2.grid = grid # reassign to the 60x60 grid +grid.fov_radius = 8 +grid.fov = mcrfpy.FOV.BASIC +e2.grid_pos = (30, 30) +e2.update_visibility() +pm_big = e2.perspective_map +if pm_big.size != (W, H): + failures.append("RESIZE FAIL: after move pm.size=%s expected=%s" + % (pm_big.size, (W, H))) + resize_ok = False + +# Verify equivalence on the resized (large) map: no stale VISIBLE, correct FOV. +ref_fov = reference_fov_set(30, 30, 8, mcrfpy.FOV.BASIC) +resize_mism = 0 +for gx in range(W): + for gy in range(H): + got = int(pm_big[gx, gy]) + exp = VISIBLE if (gx, gy) in ref_fov else got # only require: no stale 2 outside FOV + if (gx, gy) not in ref_fov and got == VISIBLE: + resize_mism += 1 + if (gx, gy) in ref_fov and got != VISIBLE: + resize_mism += 1 +if resize_mism: + failures.append("RESIZE FAIL: %d cells wrong after realloc" % resize_mism) + resize_ok = False + +print("Grid resize test: %s" % ("OK" if resize_ok else "FAIL")) + + +# ---------------------------------------------------------------------------- +# 6. EXTERNAL ASSIGNMENT (load / resume): assigning a whole DiscreteMap that +# carries VISIBLE cells anywhere must NOT leave ghost-visible cells. The +# windowed demote tracks only cells the engine itself promoted (prev_fov), so +# an externally-supplied map needs a one-shot full demote on the next +# update_visibility(): loaded VISIBLE -> DISCOVERED before the FOV recompute, +# matching the pre-#316 full-demote semantics. This is the documented +# from_bytes/assign/update_visibility workflow; the naive windowed demote +# regressed it (caught by the #316 adversarial verify, not the moving test). +# ---------------------------------------------------------------------------- +assign_entity = mcrfpy.Entity(grid_pos=(W // 2, H // 2)) +grid.entities.append(assign_entity) +grid.fov_radius = 8 +grid.fov = mcrfpy.FOV.SHADOW + +# A "saved" perspective with VISIBLE cells FAR from where the entity now stands +# (simulating a map saved while the entity was elsewhere), plus a far DISCOVERED +# cell that must be preserved (demote only touches 2 -> 1, never 1 -> 0). +saved = mcrfpy.DiscreteMap(size=(W, H), fill=0) +far_visible = [(2, 2), (3, 2), (2, 3), (55, 5), (5, 55), (57, 57)] +for (gx, gy) in far_visible: + saved[gx, gy] = VISIBLE +saved[10, 2] = DISCOVERED + +Ex, Ey = W // 2, H // 2 +assign_entity.grid_pos = (Ex, Ey) +assign_entity.perspective_map = saved # set_perspective_map -> full_demote_pending +assign_entity.update_visibility() + +pm_a = assign_entity.perspective_map +fov_assign = reference_fov_set(Ex, Ey, 8, mcrfpy.FOV.SHADOW) + +assign_ghost = 0 +assign_first = None +for (gx, gy) in far_visible: + if (gx, gy) not in fov_assign: + v = int(pm_a[gx, gy]) + if v == VISIBLE: + assign_ghost += 1 + if assign_first is None: + assign_first = (gx, gy, v) + elif v != DISCOVERED: + failures.append("ASSIGN(load/resume) FAIL: loaded VISIBLE cell %s " + "should be DISCOVERED, got %d" % ((gx, gy), v)) +if assign_ghost: + failures.append( + "ASSIGN(load/resume) FAIL: %d loaded VISIBLE cells outside FOV stayed " + "VISIBLE (ghost vision); first=%s" % (assign_ghost, assign_first)) + +# Pre-existing far DISCOVERED cell must remain DISCOVERED. +if int(pm_a[10, 2]) != DISCOVERED: + failures.append("ASSIGN(load/resume) FAIL: pre-existing DISCOVERED cell lost " + "(got %d)" % int(pm_a[10, 2])) + +# Current FOV must be VISIBLE. +assign_missing = sum(1 for (gx, gy) in fov_assign if int(pm_a[gx, gy]) != VISIBLE) +if assign_missing: + failures.append("ASSIGN(load/resume) FAIL: %d cells in FOV not VISIBLE" + % assign_missing) + +# A SECOND update with no further assignment must keep working windowed (the +# pending flag was consumed) and still leave no ghosts. +assign_entity.update_visibility() +second_ghost = sum(1 for (gx, gy) in far_visible + if (gx, gy) not in fov_assign and int(pm_a[gx, gy]) == VISIBLE) +if second_ghost: + failures.append("ASSIGN(load/resume) FAIL: %d ghosts after second update " + "(pending flag not consumed correctly)" % second_ghost) + +print("Assignment (load/resume) test: ghost=%d, missing=%d, second_update_ghost=%d" + % (assign_ghost, assign_missing, second_ghost)) + + +# ---------------------------------------------------------------------------- +# 7. AABB MARGIN LOCK: a wall ring at chebyshev distance r+1 around an open-area +# entity. With light_walls those boundary walls are lit; the windowed map must +# match a full-grid reference there, locking the +/-(r+1) window margin. Sound +# oracle (full-grid compare), so it can only fail if the window drops boundary +# cells -- and it reports how many in-FOV cells lie beyond radius r, proving +# the margin is actually exercised. +# ---------------------------------------------------------------------------- +AW, AH = 40, 40 +arena = mcrfpy.Grid(grid_size=(AW, AH)) +scene.children.append(arena) +for gx in range(AW): + for gy in range(AH): + gp = arena.at(gx, gy) + gp.walkable = True + gp.transparent = True + +acx, acy, ar = 20, 20, 6 +for gx in range(AW): + for gy in range(AH): + if max(abs(gx - acx), abs(gy - acy)) == ar + 1: + arena.at(gx, gy).transparent = False # opaque ring at distance r+1 + +arena_entity = mcrfpy.Entity(grid_pos=(acx, acy)) +arena.entities.append(arena_entity) + +margin_total = 0 +margin_pass = 0 +margin_exercised = 0 +for (aname, algo) in ALGOS: + arena.fov_radius = ar + arena.fov = algo + arena_entity.grid_pos = (acx, acy) + apm = arena_entity.perspective_map + apm.fill(0) + arena_entity.update_visibility() + + arena.compute_fov((acx, acy), radius=ar, light_walls=True, algorithm=algo) + ref = set() + for gx in range(AW): + for gy in range(AH): + if arena.is_in_fov((gx, gy)): + ref.add((gx, gy)) + + margin_total += 1 + mism = 0 + for gx in range(AW): + for gy in range(AH): + exp = VISIBLE if (gx, gy) in ref else UNKNOWN + if int(apm[gx, gy]) != exp: + mism += 1 + if mism == 0: + margin_pass += 1 + else: + failures.append("AABB MARGIN FAIL [%s]: %d cells differ from full-grid " + "reference in r+1 wall-ring arena" % (aname, mism)) + for (gx, gy) in ref: + if max(abs(gx - acx), abs(gy - acy)) > ar: + margin_exercised += 1 + +print("AABB margin lock (r+1 wall ring): %d/%d algos equivalent; %d in-FOV cells " + "beyond radius r exercised the margin" % (margin_pass, margin_total, margin_exercised)) + + +# ---------------------------------------------------------------------------- +# Summary +# ---------------------------------------------------------------------------- +print("") +if failures: + print("FAIL -- %d failure(s):" % len(failures)) + for f in failures: + print(" - " + f) + sys.exit(1) +else: + print("PASS -- windowed perspective writeback matches full-grid reference " + "across all equivalence, moving, trailing-edge, and resize cases.") + sys.exit(0)