Compare commits

...

11 commits

Author SHA1 Message Date
a7ba4867ea Merge branch 'fix/bugfix-batch-july' 2026-07-14 07:59:47 -04:00
55968d4c09 fix(bindings): pin a subclassed drawable's wrapper while a collection owns it
PythonObjectCache holds weakrefs, so #369 preserved object identity only
while Python still held a reference to the wrapper. Drop that reference
while C++ still owns the object, and the wrapper is collected; the next
.parent / find() / indexing lookup misses the cache and allocates a fresh
BASE-type wrapper. The subclass, and every attribute the user set on it,
are silently gone:

    del parent; gc.collect()
    type(kid.parent)   # mcrfpy.Frame, not MyFrame
    kid.parent.hp      # AttributeError

Entities already solve this with a strong ref from the C++ object back to
its wrapper, pinned for as long as the entity is in a grid (#266). #373
noted drawables had no equivalent boundary. They do: membership in a
children collection. It is already represented by the parent link, and
setParent()/setParentScene() are the choke points every collection mutator
goes through -- so that is where the pin is taken and dropped.

Only subclassed drawables are pinned. A base-type wrapper carries no state
of its own (the types have no __dict__), so re-creating one on a cache miss
is unobservable, and pinning every drawable ever created would cost memory
for nothing.

The pin is a reference cycle (wrapper -> shared_ptr -> drawable -> wrapper)
that Python's GC cannot see through the C++ side, so it must be released on
the way out or the object leaks forever. Every exit is covered:

  * unlinking calls setParent(nullptr), which drops the pin;
  * removeFromParent() re-evaluates it as its LAST statement, so releasing
    the last reference to the wrapper -- whose dealloc drops the wrapper's
    shared_ptr to the drawable -- cannot touch a freed `this`;
  * an owner destroyed while it still holds children releases their pins
    first (releaseChildPins), since nothing unlinks them on that path: the
    vector simply dies. Without it the whole subtree would be stranded.

Scene gains the virtual destructor it needed for that release -- it was a
polymorphic base that GameEngine deletes through a Scene*, so it needed one
regardless.

Depends on #377: the pin keys off the parent link, which four of six
collection mutators did not maintain.

closes #373

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:53:42 -04:00
53234288ad fix(ui): maintain the parent link in every collection mutator
The parent link (#122/#183) is meant to be the inverse of collection
membership, but only append() maintained it.

insert(), extend() and setitem() called setParent(owner.lock())
unconditionally. A scene collection has no owner drawable, so that
resolved to null -- and setParent(nullptr) also clears parent_scene. The
drawable landed in the scene's UI vector, rendered fine, and reported a
.parent of None.

The slice arms never touched the link at all. A slice-delete left the
removed child pointing at a parent that no longer contained it; a
slice-assign never parented the incoming items, nor unparented the ones
they displaced. A stale parent is a live weak_ptr to a frame the child
has left, and it is what get_global_position() walks.

Route every mutator through link_child()/unlink_child(), which are now
the single place that knows a scene parents via setParentScene() and a
drawable via setParent().

Three latent traps in the same functions, found while fixing this:

  * collection[-1] on an EMPTY collection hung the engine forever:
    `while (index < 0) index += size()` adds zero on every pass.
  * getitem's `index > size() - 1` check underflowed to SIZE_MAX when
    size() was 0, so the bounds check passed and it indexed an empty
    vector.
  * insert() and setitem() resolved the index before detaching the
    incoming drawable from its old parent. If it was already a member of
    the same collection, detaching erased it and shifted everything
    after -- so an index validated against the old size could write past
    the end. Slice-assign now builds its result as an independent vector
    for the same reason.

closes #377

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:53:21 -04:00
57db8cb87b fix(stubs): emit a parseable .pyi when a return type is authored as prose
MCRF_SIG return types are sometimes English rather than a type expression
(e.g. 'context manager'). Emitting them verbatim produced a stubs/mcrfpy.pyi
that could not be parsed at all, which is what tests/unit/test_stubs.py was
written to catch -- but that test had rotted and was exiting 0 without running.
Return annotations that are not valid Python expressions now fall back to Any.

Refs #372
2026-07-14 07:29:56 -04:00
112f3571f5 test(suite): unrot 82 tests that were passing without ever running
The suite was reporting 331/331 while at least 82 of those tests asserted
nothing at all.

They raised during setup on APIs removed long ago -- add_layer(name=...),
GridPoint.color, mcrfpy.Animation(), entity.gridstate, mcrfpy.setScene,
assets/kenney_ice.png, GridData.compute_astar -- registered no timers, hit the
engine's auto-exit-when-no-timers path, exited 0, and were scored PASS. Their
assertions had not executed in months. test_metrics.py is the sharpest example:
the existing metrics test died on line 140 with a TypeError, which is precisely
why #341 (get_metrics counters reading 0) went unnoticed.

The engine no longer permits this (#350: a headless --exec script must call
sys.exit()), and run_tests.py no longer passes a test whose output contains a
Traceback. This commit repairs the 82 they exposed, migrating each to the
current API while preserving its original intent -- not deleting assertions to
make the command exit 0. Each repair was adversarially re-verified by a second
pass asking "is this still a test, or was it gutted?"; none were.

Two tests could not be made to pass because they were right and the engine was
wrong. Rather than paper over them they were left failing and the bugs fixed
separately in 48eef0b: DijkstraMap path order (#375) and layer-setter cache
invalidation (#376). Three integration tests had encoded the reversed Dijkstra
order as expected behavior; their assertions now state the real contract
(excludes the origin, ends at the root).

Suite: 334/334, every one of them actually asserting.

Refs #341, #350, #372
2026-07-14 07:29:23 -04:00
48eef0b095 fix(grid): correct Dijkstra path order and layer-setter cache invalidation
Two real engine bugs, both surfaced by unrotting tests that had been silently
scored as passing for months, and both independently reproduced before fixing.

#375 -- DijkstraMap.path_from() returned the path REVERSED, and step_from()
inherited the error, returning a cell that is not adjacent to the query
position. libtcod builds its list as [pos, ..., root_adjacent] and
TCOD_dijkstra_path_walk() pops from the BACK, so the collecting loop produced
[root_adjacent, ..., pos]. On an open grid with root (5,5):

    path_from((9,5)) -> [(6,5),(7,5),(8,5),(9,5)]   # marched AWAY from the root
    step_from((9,5)) -> (6,5)                       # a three-cell teleport

That contradicted path_from's own docstring, AStarPath.origin/.destination,
and find_path()'s convention. step_from was the dangerous one: an entity
chasing a Dijkstra root would jump next to the root on its first step. The
path is now emitted origin->destination, excluding the origin and including
the root (found via descentStep, so multi-root lands on the right root), and
step_from returns its first element -- provably adjacent.

The behavior system was never affected: PathProvider (SEEK/FLEE) uses
descentStep, which was already correct. getPathFrom/stepFrom had no internal
C++ callers.

#376 -- ColorLayer/TileLayer .visible and .z_index setters never called
markDirty(), so they did not bump content_generation, which is the key the
#351 render early-out uses. Hiding a layer left the rendered image
byte-identical (sha256 unchanged) until some unrelated mutation happened to
dirty it; re-showing it did not restore it. z_index carried an explicit
"TODO: Trigger re-sort in parent grid" and had the same defect. Both setters
now invalidate on change. (Only observable after #351 stopped redrawing
unconditionally -- the same masking story as the entity set_position bug.)

Neither was caught because no test asserted path ORDER (test_astar checks only
length and wall-avoidance, both invariant under reversal), and because
issue_148_layer_dirty_flags.py -- the test literally named for this defect
class -- had rotted and was exiting 0 without running.

closes #375
closes #376
2026-07-14 07:29:03 -04:00
bd0b913d7d fix(demos): unrot tests/demo and gate it in CI
tests/demo/demo_main.py died immediately on `mcrfpy.sceneUI`, removed with the
pre-Scene-object API. Four bugs, not the one filed:

  1. screens/base.py called mcrfpy.sceneUI(name). The Scene object it had already
     constructed owns the UI collection: scene.children.
  2. demo_main.py called the removed mcrfpy.setScene().
  3. run_interactive() referenced `menu`, a local of create_menu() -- NameError on
     every interactive launch, independent of the sceneUI bug.
  4. run_headless() assigned a DemoScreen (not a Scene) to mcrfpy.current_scene ->
     TypeError, so the headless screenshot path was broken too.

The screens themselves had rotted the same way as the unit suite: add_layer() no
longer takes keyword arguments or a name (it takes a layer object), layer.set()
takes a position tuple, and mcrfpy.Animation is no longer exported (targets animate
themselves). Fixed in grid_demo and animation_demo, including the code samples the
demos display -- the demos are documentation, so a stale sample is a stale doc.

The headless path also no longer needs a Timer at all. Rendering costs zero
simulation time (#341/#350), so it just activates each scene and screenshots it,
rather than waiting for frames that headless never runs.

Six showcase scripts wrote to a hardcoded /opt/goblincorps/... absolute path outside
the repo, so they raised PermissionError for anyone else. Replaced with
tests/demo/docs_output.py: $MCRF_DOCS_REPO, else a side-by-side mcrogueface.github.io
checkout, else tests/demo/screenshots/.

The point of the issue was the gate, so: run_tests.py now runs demo_main.py as a
smoke test. Verified it catches the original bug -- reintroducing the sceneUI call
turns the demo suite red. CLAUDE.md tells newcomers to read tests/demo/screens/ for
correct API usage; nothing was checking that it ran.

The 19 standalone showcase scripts under screens/ are not on the demo_main path and
are not adopted here; follow-up issue to come.

closes #372
2026-07-14 06:46:03 -04:00
30abb0b7ec fix(engine): headless step() is a full simulation frame; --exec can no longer hang
step() advanced animations and timers only. It never called updatePythonScenes(),
which lives solely in doFrame() -- so headless Scene.update(dt) overrides never
fired. That was the filed bug, but it was one of twelve omissions: the C++ scene
update never ran, scene transitions never progressed or COMPLETED, and
current_frame never advanced. Headless behaved measurably differently from a real
frame, and any game with per-frame logic in update() was untestable.

step() is now a full simulation frame: scene update, timers, Python scene update,
animations, transitions (including completion), frame-time metrics, currentFrame++.
Render and input stay out, deliberately -- rendering is orthogonal to the clock and
costs zero simulation time (see renderScene(); a screenshot draws arbitrary state
without time passing). Timers remain on simulation_time: explicit, deterministic
time is the entire point of driving a headless test with step().

This exposed the deeper bug. testTimers() reads simulation_time when headless, and
simulation_time is advanced ONLY by step() -- the doFrame run loop never touched it.
So the headless run loop spun forever on a frozen clock: any headless script that
registered a timer and did not call step() hung until killed. And a script that died
during setup registered no timers, hit the auto-exit-when-no-timers path, exited 0,
and was scored as a PASSING test.

So headless --exec now exits when its scripts finish, nonzero if a script fell off
the end without calling sys.exit(). step() is the clock; the engine cannot advance
time on its own, and pretending otherwise is what produced the hang. A genuinely
long-lived headless process (server, REPL host) opts in with the new --run-forever.

run_tests.py additionally refuses to pass any test whose output contains a
Traceback. Between the two, 82 tests that were being scored green while asserting
nothing are now correctly red; they are repaired in the following commits.

closes #350
2026-07-14 06:37:52 -04:00
c72ab22999 fix(metrics): report counters Python can actually observe
get_metrics() returned 0 for draw_calls, ui_elements, visible_elements and
every grid counter. Two distinct defects behind one symptom.

Ordering. draw_calls/ui_elements/visible_elements WERE incremented, in
PyScene::render() -- but render runs at step 6 of the frame, after every
Python callback, while the counters were zeroed at step 1. A script calling
get_metrics() from a timer or scene update was structurally incapable of ever
observing a nonzero value.

Dead code. grid_cells_rendered, entities_rendered, total_entities,
grid_render_time and entity_render_time were never incremented ANYWHERE in
src/ -- the increments were lost in the GridView/chunk refactor and only
fovOverlayTime survived. They are re-instrumented in UIGridView::render().

Fix both by splitting the metrics along the seam that actually exists:
rendering is orthogonal to simulation and costs zero clock time (a screenshot
draws arbitrary state without advancing time). So render counters are cleared
and published by the render pass (beginRender/endRender, in both doFrame and
the on-demand renderScene), and simulation timings by the sim pass
(beginSimFrame/endSimFrame). get_metrics() reads the published values, so a
step()-only run cannot wipe the counters from the last render, and a render
cannot disturb the clock.

Riders found while in here:
- ScopedTimer ASSIGNS, so with two grid views on screen the second view's time
  silently replaced the first's. Added ScopedAccumTimer for per-frame totals
  accumulated across objects; fovOverlayTime had this bug already.
- fps was inflated for the first 60 frames: frameTimeHistory is zero-filled but
  the mean always divided by the full 60. Now divides by frames actually seen.
- frame_time is milliseconds; the docstring said seconds. Fixed the docstring,
  not the value -- the Gauntlet already reads it as ms.

closes #341
2026-07-14 06:37:33 -04:00
464af6a549 fix(bindings): make dynamic module attributes dir()-discoverable
mcrfpy.current_scene, .scenes, .timers, .animations, .default_transition,
.default_transition_duration and .save_dir were served by a PEP-562 module
__getattr__. getattr/hasattr worked, but the names were absent from
dir(mcrfpy) -- and all three generators (docs, stubs, api manifest) discover
module symbols via dir(). So the single most common idiom in the engine,
`mcrfpy.current_scene = ...`, appeared in no generated documentation at all,
and had to be hand-authored and hand-verified against a live binary for the
website.

Replace the __getattr__/tp_setattro string-matching with real getset
descriptors on McRFPyModuleType (which was already a custom module type, so
this is a small change) carrying MCRF_PROPERTY docstrings.

Descriptors alone are NOT sufficient, contrary to what the issue proposed:
CPython's module.__dir__ reports only the module __dict__ keys, so type-level
descriptors stay invisible to dir(). Verified empirically, then fixed by
overriding __dir__ on the module type to union the two. This is the part that
actually closes the bug.

The generators then need to read the DESCRIPTOR, not getattr()'s value: at
generation time current_scene is None, which is how a first pass typed it
`current_scene: NoneType`. All three now introspect type(mcrfpy), and
api_delta.py learns a module_attributes section so these can no longer drift
silently. The api-surface snapshot gets its own dynamic-attribute section for
the same reason -- keying it on the value type would make the golden flip
depending on whether a scene happened to be active.

Behavior preserved: read-only attrs still raise AttributeError on assignment,
range/type validation on the writable ones is unchanged, and unknown
attributes still raise AttributeError.

Suite 331/331.

closes #356
2026-07-13 23:47:04 -04:00
ce15469ffc fix(bindings): route every drawable wrapper through PythonObjectCache
`.parent` allocated a brand-new Python wrapper on every read, so object
identity never held: `kid.parent is parent` was False, and so was
`kid.parent is kid.parent`. A Frame/Grid subclass reached through .parent
came back as the base type with its Python attributes gone, and `.parent`
could not be used as a dict key or set member.

The cache-aware converter already existed -- convertDrawableToPython in
UICollection.cpp -- but was file-local, so three other paths hand-rolled
their own tp_alloc switch and skipped the cache:

  - UIDrawable::get_parent      (the filed bug)
  - find_in_collection          (mcrfpy.find / find_all)
  - UIEntityCollection concat and slice __getitem__

The entity sites were the worst of them: a duplicate wrapper's tp_dealloc
unconditionally clears UIEntity::pyobject, destroying the #266 subclass
identity ref held by the *original* wrapper.

Promote the converter to UIDrawable::pyobject_for and route all sites
through it; add the matching convertEntityToPython for entities. This also
gives .parent the Line/Circle/Arc/Viewport3D arms it never had (they
previously fell through to None).

Residual gap, deliberately not closed here: the cache holds weakrefs, so a
subclass wrapper GC'd while only C++ holds the object still resurrects as
the base type. Closing that needs an owner-held strong ref (the #348
pattern) and is a lifetime design decision; filed separately.

Verified A/B: 17 of the new test's 22 checks fail pre-fix, all pass after.
Suite 330/330. Also corrects CLAUDE.md, which documented the long-deleted
RET_PY_INSTANCE macro and recommended bare tp_alloc as the "most common"
pattern -- i.e. taught this exact bug.

closes #369
2026-07-13 23:37:01 -04:00
128 changed files with 8478 additions and 4012 deletions

View file

@ -609,34 +609,29 @@ echo 'import mcrfpy; print("Test"); scene = mcrfpy.Scene("test"); scene.activate
### Understanding Key Macros and Patterns
#### RET_PY_INSTANCE Macro (UIDrawable.h)
This macro handles converting C++ UI objects to their Python equivalents:
#### Returning a C++ object to Python (#369)
**Never `tp_alloc` a wrapper for a C++ object that might already have one.** A fresh
wrapper breaks object identity (`x.parent is x.parent` -> False) and silently downgrades
a Python subclass to its base type. `RET_PY_INSTANCE`, which older docs described here,
no longer exists; use the cache-aware converters instead:
```cpp
RET_PY_INSTANCE(target);
// Expands to a switch on target->derived_type() that:
// 1. Allocates the correct Python object type (Frame, Caption, Sprite, Grid)
// 2. Sets the shared_ptr data member
// 3. Returns the PyObject*
// Drawables (Frame, Caption, Sprite, GridView, Line, Circle, Arc, Viewport3D)
PyObject* obj = UIDrawable::pyobject_for(drawable_shared_ptr); // UIDrawable.h
// Entities -- convertEntityToPython(), file-local to UIEntityCollection.cpp
// GridData -- PyGridData::pyobject_for()
```
Each looks the object up in `PythonObjectCache` by `serial_number` and returns the live
wrapper if one exists, only allocating (and re-registering) on a miss. `tp_alloc` is
correct **only** in a type's own `tp_init`, where the object is genuinely new.
#### Collection Patterns
- `UICollection` wraps `std::vector<std::shared_ptr<UIDrawable>>`
- `UIEntityCollection` wraps `std::list<std::shared_ptr<UIEntity>>`
- Different containers require different iteration code (vector vs list)
#### Python Object Creation Patterns
```cpp
// Pattern 1: Using tp_alloc (most common)
auto o = (PyUIFrameObject*)type->tp_alloc(type, 0);
o->data = std::make_shared<UIFrame>();
// Pattern 2: Getting type from module
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
// Pattern 3: Direct shared_ptr assignment
iterObj->data = self->data; // Shares the C++ object
```
- `UIEntityCollection` wraps `std::vector<std::shared_ptr<UIEntity>>` (#329: was a
`std::list`; indexing is O(1) now, so don't reintroduce `std::advance`)
### Working Directory Structure
```

File diff suppressed because one or more lines are too long

View file

@ -86,6 +86,12 @@ CommandLineParser::ParseResult CommandLineParser::parse(McRogueFaceConfig& confi
continue;
}
if (arg == "--run-forever") {
config.run_forever = true;
current_arg++;
continue;
}
if (arg == "--audio-off") {
config.audio_enabled = false;
current_arg++;

View file

@ -394,8 +394,9 @@ void GameEngine::run()
void GameEngine::doFrame()
{
// Reset per-frame metrics
metrics.resetPerFrame();
// #341: simulation metrics belong to this frame's sim pass; render counters are
// cleared/published around the render pass below.
metrics.beginSimFrame();
currentScene()->update();
testTimers();
@ -426,6 +427,8 @@ void GameEngine::doFrame()
{
}
metrics.beginRender(); // #341
// Handle scene transitions
if (transition.type != TransitionType::None)
{
@ -453,6 +456,8 @@ void GameEngine::doFrame()
currentScene()->render();
}
metrics.endRender(); // #341: publish this frame's counters for get_metrics()
// Update and render profiler overlay (if enabled)
if (profilerOverlay && !headless) {
profilerOverlay->update(metrics);
@ -923,12 +928,20 @@ float GameEngine::step(float dt) {
simulation_time += static_cast<int>(dt * 1000.0f); // Convert seconds to ms
}
// Update animations with the dt in seconds
if (actual_dt > 0.0f && actual_dt < 10.0f) { // Sanity check
AnimationManager::getInstance().update(actual_dt);
}
// #350: step() is a full SIMULATION frame -- everything doFrame() does except
// render and input, which are deliberately not on the clock (render costs zero
// simulation time; see renderScene()). Previously step() advanced only animations
// and timers, so under step() a Scene.update() override never fired, scene
// transitions never progressed or completed, and current_frame never advanced --
// headless behaved measurably differently from a real frame.
metrics.beginSimFrame();
// Test timers with the new simulation time
// C++ scene update
currentScene()->update();
// Test timers with the new simulation time.
// Kept on simulation_time (not runtime): step() is the deterministic headless
// clock, which is the whole point of driving time explicitly from a test.
auto it = timers.begin();
while (it != timers.end()) {
auto timer = it->second;
@ -946,13 +959,46 @@ float GameEngine::step(float dt) {
}
}
// Python Scene.update(dt) hook -- the originally-filed omission
{
ScopedTimer pyTimer(metrics.pythonScriptTime);
McRFPy_API::updatePythonScenes(actual_dt);
}
// Update animations with the dt in seconds
if (actual_dt > 0.0f && actual_dt < 10.0f) { // Sanity check
ScopedTimer animTimer(metrics.animationTime);
AnimationManager::getInstance().update(actual_dt);
}
// Advance scene transitions, and finalize them when complete. Without this a
// transition started headlessly would never end, and the scene never changed.
if (transition.type != TransitionType::None) {
transition.update(actual_dt);
if (transition.isComplete()) {
scene = transition.toScene;
transition.type = TransitionType::None;
McRFPy_API::triggerSceneChange(transition.fromScene, transition.toScene);
}
}
metrics.endSimFrame();
metrics.updateFrameTime(actual_dt * 1000.0f); // ms
currentFrame++;
return actual_dt;
}
// #153 - Force render the current scene (for synchronous screenshots)
// Render the current state on demand, advancing NO simulation time (#341/#350).
// Rendering is orthogonal to the clock: any state can be drawn at any moment. This is
// the path a screenshot takes, and -- since step() deliberately never renders -- it is
// how a headless script gets real render metrics without the clock moving.
void GameEngine::renderScene() {
if (!render_target) return;
metrics.beginRender();
// Handle scene transitions
if (transition.type != TransitionType::None) {
transition.update(0); // Don't advance transition time, just render current state
@ -963,6 +1009,8 @@ void GameEngine::renderScene() {
currentScene()->render();
}
metrics.endRender();
// For RenderTexture (headless), we need to call display()
if (headless && headless_renderer) {
headless_renderer->display();

View file

@ -53,37 +53,90 @@ struct ProfilingMetrics {
static constexpr int HISTORY_SIZE = 60;
float frameTimeHistory[HISTORY_SIZE] = {0};
int historyIndex = 0;
int historyCount = 0; // #341: frames recorded so far, capped at HISTORY_SIZE
/**
* @brief Per-frame values from the last COMPLETED frame (#341).
*
* The counters above are live accumulators: they are zeroed at the top of the
* frame and filled during render(), which runs AFTER every Python callback.
* A script calling get_metrics() therefore always read zeros. These published
* copies hold the previous frame's totals, which is what Python should see.
*
* In-engine consumers that run after render() (ProfilerOverlay, BenchmarkLogger)
* keep reading the live values -- they are fresher and already correct there.
*/
struct PublishedFrame {
int drawCalls = 0;
int uiElements = 0;
int visibleElements = 0;
int gridCellsRendered = 0;
int entitiesRendered = 0;
int totalEntities = 0;
float gridRenderTime = 0.0f;
float entityRenderTime = 0.0f;
float fovOverlayTime = 0.0f;
float pythonScriptTime = 0.0f;
float animationTime = 0.0f;
} published;
void updateFrameTime(float deltaMs) {
frameTime = deltaMs;
frameTimeHistory[historyIndex] = deltaMs;
historyIndex = (historyIndex + 1) % HISTORY_SIZE;
if (historyCount < HISTORY_SIZE) ++historyCount;
// Calculate average
// Average over frames actually recorded. Dividing by the full HISTORY_SIZE
// while the zero-initialized buffer is still filling diluted the average and
// reported a wildly inflated FPS for the first 60 frames (#341).
float sum = 0.0f;
for (int i = 0; i < HISTORY_SIZE; ++i) {
for (int i = 0; i < historyCount; ++i) {
sum += frameTimeHistory[i];
}
avgFrameTime = sum / HISTORY_SIZE;
avgFrameTime = historyCount > 0 ? sum / historyCount : 0.0f;
fps = avgFrameTime > 0 ? static_cast<int>(1000.0f / avgFrameTime) : 0;
}
void resetPerFrame() {
// #341/#350: simulation and rendering are separate passes with separate metrics.
//
// Rendering costs zero simulation time and can happen at any moment (a screenshot
// renders arbitrary state without advancing the clock), so the render counters are
// owned by the render pass and the simulation timings by the step/frame pass.
// Keeping them separate means a step() cannot wipe the counters from the last
// render, and a render cannot disturb the simulation clock.
void beginRender() {
drawCalls = 0;
uiElements = 0;
visibleElements = 0;
// Reset per-frame timing metrics
gridRenderTime = 0.0f;
entityRenderTime = 0.0f;
fovOverlayTime = 0.0f;
pythonScriptTime = 0.0f;
animationTime = 0.0f;
// Reset per-frame counters
gridCellsRendered = 0;
entitiesRendered = 0;
totalEntities = 0;
gridRenderTime = 0.0f;
entityRenderTime = 0.0f;
fovOverlayTime = 0.0f;
}
void endRender() {
published.drawCalls = drawCalls;
published.uiElements = uiElements;
published.visibleElements = visibleElements;
published.gridCellsRendered = gridCellsRendered;
published.entitiesRendered = entitiesRendered;
published.totalEntities = totalEntities;
published.gridRenderTime = gridRenderTime;
published.entityRenderTime = entityRenderTime;
published.fovOverlayTime = fovOverlayTime;
}
void beginSimFrame() {
pythonScriptTime = 0.0f;
animationTime = 0.0f;
}
void endSimFrame() {
published.pythonScriptTime = pythonScriptTime;
published.animationTime = animationTime;
}
};

View file

@ -1707,8 +1707,13 @@ int PyGridLayerAPI::ColorLayer_set_z_index(PyColorLayerObject* self, PyObject* v
}
long z = PyLong_AsLong(value);
if (PyErr_Occurred()) return -1;
self->data->z_index = z;
// TODO: Trigger re-sort in parent grid
if (z != self->data->z_index) {
self->data->z_index = z;
// #376: the render early-out (#351) keys on content_generation, which only
// markDirty() bumps. Without this the view re-blits its stale cached texture
// and the new layer order never appears. (sortLayers() runs every render.)
self->data->markDirty();
}
return 0;
}
@ -1727,7 +1732,12 @@ int PyGridLayerAPI::ColorLayer_set_visible(PyColorLayerObject* self, PyObject* v
}
int v = PyObject_IsTrue(value);
if (v < 0) return -1;
self->data->visible = v;
if (v != self->data->visible) {
self->data->visible = v;
// #376: data mutators (fill/set/edit) markDirty(); this setter did not, so
// hiding or showing a layer left the cached render untouched in both directions.
self->data->markDirty();
}
return 0;
}
@ -2391,8 +2401,10 @@ int PyGridLayerAPI::TileLayer_set_z_index(PyTileLayerObject* self, PyObject* val
}
long z = PyLong_AsLong(value);
if (PyErr_Occurred()) return -1;
self->data->z_index = z;
// TODO: Trigger re-sort in parent grid
if (z != self->data->z_index) {
self->data->z_index = z;
self->data->markDirty(); // #376: see ColorLayer_set_z_index
}
return 0;
}
@ -2411,7 +2423,10 @@ int PyGridLayerAPI::TileLayer_set_visible(PyTileLayerObject* self, PyObject* val
}
int v = PyObject_IsTrue(value);
if (v < 0) return -1;
self->data->visible = v;
if (v != self->data->visible) {
self->data->visible = v;
self->data->markDirty(); // #376: see ColorLayer_set_visible
}
return 0;
}

View file

@ -127,108 +127,175 @@ static PyObject* mcrfpy_sync_storage(PyObject* self, PyObject* args)
Py_RETURN_NONE;
}
// #151: Module-level __getattr__ for dynamic properties (current_scene, scenes)
static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args)
// #151/#356: the module's dynamic attributes, as real descriptors on the module type.
//
// These were originally a PEP-562 `__getattr__` plus a string-matching `tp_setattro`.
// That worked for getattr/setattr but left them invisible to `dir(mcrfpy)`, and every
// doc/stub/manifest generator discovers module symbols via dir() -- so the single most
// common idiom in the engine (`mcrfpy.current_scene = ...`) appeared in no generated
// documentation at all. Descriptors are introspectable and carry their own docstrings.
static PyObject* mcrfpy_get_current_scene(PyObject* self, void* closure)
{
const char* name;
if (!PyArg_ParseTuple(args, "s", &name)) {
return McRFPy_API::api_get_current_scene();
}
static int mcrfpy_set_current_scene(PyObject* self, PyObject* value, void* closure)
{
if (!value) {
PyErr_SetString(PyExc_AttributeError, "cannot delete 'current_scene'");
return -1;
}
return McRFPy_API::api_set_current_scene(value);
}
static PyObject* mcrfpy_get_scenes(PyObject* self, void* closure)
{
return McRFPy_API::api_get_scenes();
}
static PyObject* mcrfpy_get_timers(PyObject* self, void* closure)
{
return McRFPy_API::api_get_timers();
}
static PyObject* mcrfpy_get_animations(PyObject* self, void* closure)
{
return McRFPy_API::api_get_animations();
}
static PyObject* mcrfpy_get_default_transition(PyObject* self, void* closure)
{
return PyTransition::to_python(PyTransition::default_transition);
}
static int mcrfpy_set_default_transition(PyObject* self, PyObject* value, void* closure)
{
if (!value) {
PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition'");
return -1;
}
TransitionType trans;
if (!PyTransition::from_arg(value, &trans, nullptr)) {
return -1;
}
PyTransition::default_transition = trans;
return 0;
}
static PyObject* mcrfpy_get_default_transition_duration(PyObject* self, void* closure)
{
return PyFloat_FromDouble(PyTransition::default_duration);
}
static int mcrfpy_set_default_transition_duration(PyObject* self, PyObject* value, void* closure)
{
if (!value) {
PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition_duration'");
return -1;
}
double duration;
if (PyFloat_Check(value)) {
duration = PyFloat_AsDouble(value);
} else if (PyLong_Check(value)) {
duration = PyLong_AsDouble(value);
} else {
PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number");
return -1;
}
if (duration < 0.0) {
PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative");
return -1;
}
PyTransition::default_duration = static_cast<float>(duration);
return 0;
}
static PyObject* mcrfpy_get_save_dir(PyObject* self, void* closure)
{
#ifdef __EMSCRIPTEN__
return PyUnicode_FromString("/save");
#else
return PyUnicode_FromString("save");
#endif
}
static PyGetSetDef mcrfpy_module_getset[] = {
{"current_scene", mcrfpy_get_current_scene, mcrfpy_set_current_scene,
MCRF_PROPERTY(current_scene,
"The active scene (Scene). Assign a Scene object, or a scene name as a string, "
"to switch scenes."
), NULL},
{"scenes", mcrfpy_get_scenes, NULL,
MCRF_PROPERTY(scenes,
"Tuple of all registered Scene objects (tuple, read-only)."
), NULL},
{"timers", mcrfpy_get_timers, NULL,
MCRF_PROPERTY(timers,
"Tuple of all active Timer objects (tuple, read-only)."
), NULL},
{"animations", mcrfpy_get_animations, NULL,
MCRF_PROPERTY(animations,
"Tuple of all currently running Animation objects (tuple, read-only)."
), NULL},
{"default_transition", mcrfpy_get_default_transition, mcrfpy_set_default_transition,
MCRF_PROPERTY(default_transition,
"Default transition (Transition) applied when switching scenes without an "
"explicit transition."
), NULL},
{"default_transition_duration", mcrfpy_get_default_transition_duration,
mcrfpy_set_default_transition_duration,
MCRF_PROPERTY(default_transition_duration,
"Default scene-transition duration in seconds (float). Must be non-negative."
), NULL},
{"save_dir", mcrfpy_get_save_dir, NULL,
MCRF_PROPERTY(save_dir,
"Directory used for persistent save data (str, read-only). '/save' under Emscripten."
), NULL},
{NULL} // Sentinel
};
// #356: PyModule_Type.__dir__ returns only the module __dict__ keys, so descriptors
// living on the module *type* would still be invisible to dir(). Override it to union
// the two -- this is what actually makes the generators (and tab-completion) see them.
static PyObject* mcrfpy_module_dir(PyObject* self, PyObject* Py_UNUSED(ignored))
{
PyObject* dict = PyModule_GetDict(self); // borrowed
if (!dict) return NULL;
PyObject* names = PyDict_Keys(dict);
if (!names) return NULL;
for (PyGetSetDef* gs = mcrfpy_module_getset; gs->name != NULL; gs++) {
PyObject* name = PyUnicode_FromString(gs->name);
if (!name || PyList_Append(names, name) < 0) {
Py_XDECREF(name);
Py_DECREF(names);
return NULL;
}
Py_DECREF(name);
}
if (PyList_Sort(names) < 0) {
Py_DECREF(names);
return NULL;
}
if (strcmp(name, "current_scene") == 0) {
return McRFPy_API::api_get_current_scene();
}
if (strcmp(name, "scenes") == 0) {
return McRFPy_API::api_get_scenes();
}
if (strcmp(name, "timers") == 0) {
return McRFPy_API::api_get_timers();
}
if (strcmp(name, "animations") == 0) {
return McRFPy_API::api_get_animations();
}
if (strcmp(name, "default_transition") == 0) {
return PyTransition::to_python(PyTransition::default_transition);
}
if (strcmp(name, "default_transition_duration") == 0) {
return PyFloat_FromDouble(PyTransition::default_duration);
}
if (strcmp(name, "save_dir") == 0) {
#ifdef __EMSCRIPTEN__
return PyUnicode_FromString("/save");
#else
return PyUnicode_FromString("save");
#endif
}
// Attribute not found - raise AttributeError
PyErr_Format(PyExc_AttributeError, "module 'mcrfpy' has no attribute '%s'", name);
return NULL;
return names;
}
// #151: Custom module type with __setattr__ support for current_scene
static int mcrfpy_module_setattro(PyObject* self, PyObject* name, PyObject* value)
{
const char* name_str = PyUnicode_AsUTF8(name);
if (!name_str) return -1;
static PyMethodDef mcrfpy_module_type_methods[] = {
{"__dir__", mcrfpy_module_dir, METH_NOARGS,
MCRF_METHOD(mcrfpy, __dir__,
MCRF_SIG("()", "list"),
MCRF_DESC("Names of the module's attributes, including the dynamic properties "
"(current_scene, scenes, timers, ...) that live as descriptors on the "
"module type rather than in the module dict."),
MCRF_RETURNS("list: sorted attribute names")
)},
{NULL} // Sentinel
};
if (strcmp(name_str, "current_scene") == 0) {
return McRFPy_API::api_set_current_scene(value);
}
if (strcmp(name_str, "scenes") == 0) {
PyErr_SetString(PyExc_AttributeError, "'scenes' is read-only");
return -1;
}
if (strcmp(name_str, "timers") == 0) {
PyErr_SetString(PyExc_AttributeError, "'timers' is read-only");
return -1;
}
if (strcmp(name_str, "animations") == 0) {
PyErr_SetString(PyExc_AttributeError, "'animations' is read-only");
return -1;
}
if (strcmp(name_str, "default_transition") == 0) {
TransitionType trans;
if (!PyTransition::from_arg(value, &trans, nullptr)) {
return -1;
}
PyTransition::default_transition = trans;
return 0;
}
if (strcmp(name_str, "default_transition_duration") == 0) {
double duration;
if (PyFloat_Check(value)) {
duration = PyFloat_AsDouble(value);
} else if (PyLong_Check(value)) {
duration = PyLong_AsDouble(value);
} else {
PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number");
return -1;
}
if (duration < 0.0) {
PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative");
return -1;
}
PyTransition::default_duration = static_cast<float>(duration);
return 0;
}
// For other attributes, use default module setattr
return PyObject_GenericSetAttr(self, name, value);
}
// Custom module type that inherits from PyModule_Type but has our __setattr__
// Custom module type: carries the dynamic properties as descriptors (#151, #356)
static PyTypeObject McRFPyModuleType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.module",
@ -246,11 +313,15 @@ static PyTypeObject McRFPyModuleType = {
.tp_hash = NULL,
.tp_call = NULL,
.tp_str = NULL,
// tp_getattro/tp_setattro inherited from PyModule_Type: generic attribute access
// finds the tp_getset data descriptors below before touching the module dict.
.tp_getattro = NULL,
.tp_setattro = mcrfpy_module_setattro,
.tp_setattro = NULL,
.tp_as_buffer = NULL,
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
.tp_doc = "McRogueFace module with property support",
.tp_methods = mcrfpy_module_type_methods,
.tp_getset = mcrfpy_module_getset,
};
static PyMethodDef mcrfpyMethods[] = {
@ -307,7 +378,10 @@ static PyMethodDef mcrfpyMethods[] = {
MCRF_METHOD(mcrfpy, get_metrics,
MCRF_SIG("()", "dict"),
MCRF_DESC("Get current performance metrics."),
MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in seconds), avg_frame_time (average frame time), fps (frames per second), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds), grid_render_time (grid rendering time in ms), entity_render_time (entity rendering time in ms), fov_overlay_time (FOV overlay rendering time in ms), python_time (Python script execution time in ms), animation_time (animation processing time in ms), grid_cells_rendered (number of grid cells rendered this frame), entities_rendered (number of entities rendered this frame), total_entities (total entity count across all grids)")
MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in MILLISECONDS), avg_frame_time (rolling mean frame time over the last 60 frames, in milliseconds), fps (frames per second, derived from avg_frame_time -- a rolling average, not an instantaneous rate), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds), grid_render_time (grid rendering time in ms), entity_render_time (entity rendering time in ms), fov_overlay_time (FOV overlay rendering time in ms), python_time (Python script execution time in ms), animation_time (animation processing time in ms), grid_cells_rendered (grid cell draws this frame, counted per layer), entities_rendered (number of entities drawn this frame), total_entities (total entity count across all rendered grids)")
MCRF_NOTE("All per-frame counters and timing breakdowns describe the last COMPLETED frame. "
"Python callbacks run before the frame is rendered, so the in-progress frame's "
"values are not available yet; frame_time, fps, runtime and current_frame are live.")
)},
{"set_dev_console", McRFPy_API::_setDevConsole, METH_VARARGS,
@ -349,12 +423,8 @@ static PyMethodDef mcrfpyMethods[] = {
MCRF_NOTE("Messages appear in the 'logs' array of each frame in the output JSON.")
)},
// #151: Module-level attribute access for current_scene and scenes
{"__getattr__", mcrfpy_module_getattr, METH_VARARGS,
MCRF_METHOD(mcrfpy, __getattr__,
MCRF_SIG("(name: str)", "object"),
MCRF_DESC("Module-level attribute access for dynamic properties (current_scene, scenes).")
)},
// #356: the module's dynamic attributes (current_scene, scenes, timers, ...) are
// real descriptors on McRFPyModuleType now, not a PEP-562 __getattr__ shim.
// #219: Thread synchronization
{"lock", PyLock::lock, METH_NOARGS,
@ -1609,54 +1679,16 @@ static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collect
// Check this element's name
if (name_matches_pattern(drawable->name, pattern)) {
// Convert to Python object using RET_PY_INSTANCE logic
PyObject* py_obj = nullptr;
switch (drawable->derived_type()) {
case PyObjectsEnum::UIFRAME: {
auto frame = std::static_pointer_cast<UIFrame>(drawable);
auto type = &mcrfpydef::PyUIFrameType;
auto o = (PyUIFrameObject*)type->tp_alloc(type, 0);
if (o) {
o->data = frame;
py_obj = (PyObject*)o;
}
break;
}
case PyObjectsEnum::UICAPTION: {
auto caption = std::static_pointer_cast<UICaption>(drawable);
auto type = &mcrfpydef::PyUICaptionType;
auto o = (PyUICaptionObject*)type->tp_alloc(type, 0);
if (o) {
o->data = caption;
py_obj = (PyObject*)o;
}
break;
}
case PyObjectsEnum::UISPRITE: {
auto sprite = std::static_pointer_cast<UISprite>(drawable);
auto type = &mcrfpydef::PyUISpriteType;
auto o = (PyUISpriteObject*)type->tp_alloc(type, 0);
if (o) {
o->data = sprite;
py_obj = (PyObject*)o;
}
break;
}
case PyObjectsEnum::UIGRIDVIEW: {
auto gridview = std::static_pointer_cast<UIGridView>(drawable);
auto type = &mcrfpydef::PyUIGridViewType;
auto o = (PyUIGridViewObject*)type->tp_alloc(type, 0);
if (o) {
o->data = gridview;
py_obj = (PyObject*)o;
}
break;
}
default:
break;
// #369: go through the cache-aware converter so find() returns the caller's
// existing wrapper (and Python subclass), not a fresh identity-less one.
PyObject* py_obj = UIDrawable::pyobject_for(drawable);
if (py_obj == Py_None) {
Py_DECREF(py_obj);
py_obj = nullptr;
} else if (!py_obj) {
PyErr_Clear(); // unknown derived type: skip, as the old switch did
}
if (py_obj) {
if (find_all) {
PyList_Append(results, py_obj);
@ -1842,22 +1874,27 @@ PyObject* McRFPy_API::_getMetrics(PyObject* self, PyObject* args) {
PyDict_SetItemString(dict, "avg_frame_time", PyFloat_FromDouble(game->metrics.avgFrameTime));
PyDict_SetItemString(dict, "fps", PyLong_FromLong(game->metrics.fps));
// #341: report the last COMPLETED frame's per-frame values, not the live
// accumulators. Python callbacks run before render(), and the accumulators are
// zeroed at the top of the frame -- so reading them live always returned 0.
const auto& pub = game->metrics.published;
// Add draw call metrics
PyDict_SetItemString(dict, "draw_calls", PyLong_FromLong(game->metrics.drawCalls));
PyDict_SetItemString(dict, "ui_elements", PyLong_FromLong(game->metrics.uiElements));
PyDict_SetItemString(dict, "visible_elements", PyLong_FromLong(game->metrics.visibleElements));
PyDict_SetItemString(dict, "draw_calls", PyLong_FromLong(pub.drawCalls));
PyDict_SetItemString(dict, "ui_elements", PyLong_FromLong(pub.uiElements));
PyDict_SetItemString(dict, "visible_elements", PyLong_FromLong(pub.visibleElements));
// #144 - Add detailed timing breakdown (in milliseconds)
PyDict_SetItemString(dict, "grid_render_time", PyFloat_FromDouble(game->metrics.gridRenderTime));
PyDict_SetItemString(dict, "entity_render_time", PyFloat_FromDouble(game->metrics.entityRenderTime));
PyDict_SetItemString(dict, "fov_overlay_time", PyFloat_FromDouble(game->metrics.fovOverlayTime));
PyDict_SetItemString(dict, "python_time", PyFloat_FromDouble(game->metrics.pythonScriptTime));
PyDict_SetItemString(dict, "animation_time", PyFloat_FromDouble(game->metrics.animationTime));
PyDict_SetItemString(dict, "grid_render_time", PyFloat_FromDouble(pub.gridRenderTime));
PyDict_SetItemString(dict, "entity_render_time", PyFloat_FromDouble(pub.entityRenderTime));
PyDict_SetItemString(dict, "fov_overlay_time", PyFloat_FromDouble(pub.fovOverlayTime));
PyDict_SetItemString(dict, "python_time", PyFloat_FromDouble(pub.pythonScriptTime));
PyDict_SetItemString(dict, "animation_time", PyFloat_FromDouble(pub.animationTime));
// #144 - Add grid-specific metrics
PyDict_SetItemString(dict, "grid_cells_rendered", PyLong_FromLong(game->metrics.gridCellsRendered));
PyDict_SetItemString(dict, "entities_rendered", PyLong_FromLong(game->metrics.entitiesRendered));
PyDict_SetItemString(dict, "total_entities", PyLong_FromLong(game->metrics.totalEntities));
PyDict_SetItemString(dict, "grid_cells_rendered", PyLong_FromLong(pub.gridCellsRendered));
PyDict_SetItemString(dict, "entities_rendered", PyLong_FromLong(pub.entitiesRendered));
PyDict_SetItemString(dict, "total_entities", PyLong_FromLong(pub.totalEntities));
// Add general metrics
PyDict_SetItemString(dict, "current_frame", PyLong_FromLong(game->getFrame()));

View file

@ -32,6 +32,13 @@ struct McRogueFaceConfig {
// Auto-exit when no timers remain (for --headless --exec automation)
bool auto_exit_after_exec = false;
// Keep running after --exec scripts finish, instead of exiting (#350).
// Headless --exec normally exits when the scripts are done: step() is the only
// headless clock, so the engine cannot advance timers on its own and would
// otherwise spin forever. Pass --run-forever for a long-lived headless process
// (a server, a REPL host) that drives itself.
bool run_forever = false;
// Exception handling: exit on first Python callback exception (default: true)
// Use --continue-after-exceptions to disable
bool exit_on_exception = true;

View file

@ -44,6 +44,38 @@ public:
ScopedTimer& operator=(const ScopedTimer&) = delete;
};
/**
* @brief Like ScopedTimer, but ADDS its elapsed time to the target (#341).
*
* ScopedTimer assigns, which is correct for a metric measured once per frame
* (workTime, pythonScriptTime). It is wrong for anything measured once per
* *object* per frame -- with two grid views on screen, assignment means the
* second view's time overwrites the first's rather than summing. Use this for
* any per-frame total accumulated across multiple call sites.
*
* The target must be zeroed once per frame (ProfilingMetrics::resetPerFrame).
*/
class ScopedAccumTimer {
private:
std::chrono::high_resolution_clock::time_point start;
float& target_ms;
public:
explicit ScopedAccumTimer(float& target)
: target_ms(target)
{
start = std::chrono::high_resolution_clock::now();
}
~ScopedAccumTimer() {
auto end = std::chrono::high_resolution_clock::now();
target_ms += std::chrono::duration<float, std::milli>(end - start).count();
}
ScopedAccumTimer(const ScopedAccumTimer&) = delete;
ScopedAccumTimer& operator=(const ScopedAccumTimer&) = delete;
};
/**
* @brief Accumulating timer that adds elapsed time to existing value
*

View file

@ -8,6 +8,11 @@ Scene::Scene(GameEngine* g)
game = g;
ui_elements = std::make_shared<std::vector<std::shared_ptr<UIDrawable>>>();
}
Scene::~Scene()
{
UIDrawable::releaseChildPins(ui_elements); // #373
}
void Scene::registerAction(int code, std::string name)
{
actions[code] = name;

View file

@ -29,6 +29,9 @@ protected:
public:
//Scene();
Scene(GameEngine*);
// #373: releases the Python-identity pins of ui_elements. Virtual because Scene is
// a polymorphic base held and deleted by GameEngine through a Scene*.
virtual ~Scene();
virtual void update() = 0;
virtual void render() = 0;
virtual void doAction(std::string, std::string) = 0;

View file

@ -17,130 +17,35 @@
using namespace mcrfpydef;
// Local helper function to convert UIDrawable to appropriate Python object
// #369: promoted to UIDrawable::pyobject_for so .parent, find(), and collection
// indexing all hand back the same wrapper for the same C++ object.
static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
if (!drawable) {
Py_RETURN_NONE;
return UIDrawable::pyobject_for(drawable);
}
// #377: the parent link is the inverse of collection membership, so EVERY mutator
// has to maintain it -- not just append(), which was the only one that did. These
// two helpers are the single place that knows how: a scene collection parents via
// setParentScene() (it has no owner drawable, so owner.lock() is null and
// setParent() would silently unparent the child), a drawable-owned one via
// setParent().
//
// #373: they are also the pin points. setParent()/setParentScene() re-evaluate the
// strong ref that keeps a Python subclass wrapper alive while its C++ object is in
// a collection, so linking and unlinking here is what makes the pin correct.
static void link_child(PyUICollectionObject* self, std::shared_ptr<UIDrawable> drawable) {
if (!self->scene_name.empty()) {
drawable->setParentScene(self->scene_name);
} else {
drawable->setParent(self->owner.lock());
}
}
// Check cache first
if (drawable->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(drawable->serial_number);
if (cached) {
return cached; // Already INCREF'd by lookup
}
}
PyTypeObject* type = nullptr;
PyObject* obj = nullptr;
switch (drawable->derived_type()) {
case PyObjectsEnum::UIFRAME:
{
type = &PyUIFrameType;
auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIFrame>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UICAPTION:
{
type = &PyUICaptionType;
auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UICaption>(drawable);
pyObj->font = nullptr;
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UISPRITE:
{
type = &PyUISpriteType;
auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UISprite>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRIDVIEW:
{
type = &PyUIGridViewType;
auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIGridView>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UILINE:
{
type = &PyUILineType;
auto pyObj = (PyUILineObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UILine>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UICIRCLE:
{
type = &PyUICircleType;
auto pyObj = (PyUICircleObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UICircle>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIARC:
{
type = &PyUIArcType;
auto pyObj = (PyUIArcObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIArc>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIVIEWPORT3D:
{
type = &PyViewport3DType;
auto pyObj = (PyViewport3DObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<mcrf::Viewport3D>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
default:
PyErr_SetString(PyExc_TypeError, "Unknown UIDrawable derived type");
return nullptr;
}
// Re-register in cache if the object has a serial number
// This handles the case where the original Python wrapper was GC'd
// but the C++ object persists (e.g., inline-created objects added to collections)
if (obj && drawable->serial_number != 0) {
PyObject* weakref = PyWeakref_NewRef(obj, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(drawable->serial_number, weakref);
Py_DECREF(weakref);
}
}
return obj;
// Call BEFORE erasing from the vector: setParent(nullptr) may drop the last strong
// ref to the wrapper, whose dealloc releases its shared_ptr to the drawable. While
// the vector still holds the element, `drawable` cannot be freed under us.
static void unlink_child(std::shared_ptr<UIDrawable> drawable) {
drawable->setParent(nullptr);
}
// Helper to extract shared_ptr<UIDrawable> from any UIDrawable Python subclass.
@ -229,8 +134,12 @@ PyObject* UICollection::getitem(PyUICollectionObject* self, Py_ssize_t index) {
PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer");
return NULL;
}
while (index < 0) index += self->data->size();
if (index > self->data->size() - 1)
// Same two traps as setitem(): the old `while (index < 0) index += size()` spun
// forever on an empty collection, and `index > size() - 1` underflowed to SIZE_MAX
// when size() was 0 -- so the bounds check passed and we indexed an empty vector.
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
if (index < 0) index += size;
if (index < 0 || index >= size)
{
PyErr_SetString(PyExc_IndexError, "UICollection index out of range");
return NULL;
@ -248,19 +157,21 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
return -1;
}
// Handle negative indexing
while (index < 0) index += self->data->size();
// Handle negative indexing. NOT a `while (index < 0) index += size()` loop: on an
// empty collection that adds zero forever, and `children[-1] = x` hung the engine.
Py_ssize_t size = static_cast<Py_ssize_t>(self->data->size());
if (index < 0) index += size;
// Bounds check
if (index >= self->data->size()) {
if (index < 0 || index >= size) {
PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range");
return -1;
}
// Handle deletion
if (value == NULL) {
// #122: Clear the parent before removing
(*self->data)[index]->setParent(nullptr);
unlink_child((*self->data)[index]);
self->data->erase(self->data->begin() + index);
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
@ -276,21 +187,30 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
PyErr_SetString(PyExc_TypeError, "UICollection can only contain Drawable objects");
return -1;
}
int old_z_index = (*vec)[index]->z_index; // Preserve the z_index
// #183: Remove from old parent, which may be a scene rather than a drawable.
// Do this BEFORE indexing: if the incoming drawable is already a member of THIS
// collection, detaching it erases it from `vec` and shifts every later element,
// so an index validated against the old size could write past the end.
new_drawable->removeFromParent();
if (index >= static_cast<Py_ssize_t>(vec->size())) {
PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range");
return -1;
}
// Hold a strong ref: unlinking may release the last ref to the old element's
// Python wrapper, whose dealloc drops the wrapper's shared_ptr to it.
std::shared_ptr<UIDrawable> old_drawable = (*vec)[index];
int old_z_index = old_drawable->z_index; // Preserve the z_index
// #122: Clear parent of old element
(*vec)[index]->setParent(nullptr);
// #122: Remove new drawable from its old parent if it has one
if (auto old_parent = new_drawable->getParent()) {
new_drawable->removeFromParent();
}
unlink_child(old_drawable);
// Preserve the z_index of the replaced element
new_drawable->z_index = old_z_index;
// #122: Set new parent
new_drawable->setParent(self->owner.lock());
// #377: parent to the scene, not to a null owner, when this is a scene collection
link_child(self, new_drawable);
// Replace the element
(*vec)[index] = new_drawable;
@ -487,10 +407,16 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
// Sort in descending order and delete
std::sort(indices.begin(), indices.end(), std::greater<Py_ssize_t>());
for (Py_ssize_t idx : indices) {
// #377: unlink before erasing -- a slice delete used to leave the
// removed child pointing at a parent that no longer contains it
unlink_child((*self->data)[idx]);
self->data->erase(self->data->begin() + idx);
}
} else {
// Contiguous slice - can delete in one go
for (Py_ssize_t i = start; i < stop; i++) {
unlink_child((*self->data)[i]);
}
self->data->erase(self->data->begin() + start, self->data->begin() + stop);
}
@ -539,38 +465,67 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
new_items.push_back(drawable);
}
// Now perform the assignment
if (step != 1 && slicelength != value_len) {
PyErr_Format(PyExc_ValueError,
"attempt to assign sequence of size %zd to extended slice of size %zd",
value_len, slicelength);
return -1;
}
// #377: build the post-assignment contents as an independent vector before
// touching anything. An incoming item may already be a member of THIS
// collection, and detaching it from its old parent mutates the very vector
// we would otherwise be indexing into -- shifting `start`/`stop` out from
// under the erase. Assembling a copy first makes aliasing a non-issue.
//
// `previous` also holds a strong ref to every displaced element, so
// unlinking one (which may release the last ref to its Python wrapper,
// whose dealloc drops the wrapper's shared_ptr) cannot free it under us.
std::vector<std::shared_ptr<UIDrawable>> previous = *self->data;
std::vector<std::shared_ptr<UIDrawable>> result = previous;
if (step == 1) {
// Contiguous slice
if (slicelength != value_len) {
// Need to resize
auto it_start = self->data->begin() + start;
auto it_stop = self->data->begin() + stop;
self->data->erase(it_start, it_stop);
self->data->insert(self->data->begin() + start, new_items.begin(), new_items.end());
result.erase(result.begin() + start, result.begin() + stop);
result.insert(result.begin() + start, new_items.begin(), new_items.end());
} else {
// Same size, just replace
for (Py_ssize_t i = 0; i < slicelength; i++) {
// Preserve z_index
new_items[i]->z_index = (*self->data)[start + i]->z_index;
(*self->data)[start + i] = new_items[i];
new_items[i]->z_index = previous[start + i]->z_index; // Preserve z_index
result[start + i] = new_items[i];
}
}
} else {
// Extended slice
if (slicelength != value_len) {
PyErr_Format(PyExc_ValueError,
"attempt to assign sequence of size %zd to extended slice of size %zd",
value_len, slicelength);
return -1;
}
for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) {
// Preserve z_index
new_items[i]->z_index = (*self->data)[cur]->z_index;
(*self->data)[cur] = new_items[i];
new_items[i]->z_index = previous[cur]->z_index; // Preserve z_index
result[cur] = new_items[i];
}
}
auto contains_ptr = [](const std::vector<std::shared_ptr<UIDrawable>>& v,
const UIDrawable* p) {
return std::any_of(v.begin(), v.end(),
[p](const std::shared_ptr<UIDrawable>& e) { return e.get() == p; });
};
// Detach incoming items from whatever parent they had. Safe to do now:
// this can only mutate `*self->data`, which we are about to overwrite.
for (auto& item : new_items) {
item->removeFromParent();
}
*self->data = result;
// Anything the slice displaced is no longer a member -- drop its parent link.
for (auto& old_item : previous) {
if (!contains_ptr(result, old_item.get())) {
unlink_child(old_item);
}
}
for (auto& item : result) {
link_child(self, item);
}
// Mark scene as needing resort after slice assignment
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
@ -639,12 +594,7 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o)
drawable->z_index = new_z_index;
// #183: Set new parent - either scene or drawable
if (!self->scene_name.empty()) {
drawable->setParentScene(self->scene_name);
} else {
std::shared_ptr<UIDrawable> owner_ptr = self->owner.lock();
drawable->setParent(owner_ptr);
}
link_child(self, drawable);
self->data->push_back(drawable);
@ -696,7 +646,9 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable)
drawable->removeFromParent();
drawable->z_index = current_z_index;
drawable->setParent(owner_ptr);
// #377: was setParent(owner_ptr), which unparented the drawable outright on a
// scene collection (no owner drawable => owner.lock() is null)
link_child(self, drawable);
self->data->push_back(drawable);
Py_DECREF(item);
@ -741,7 +693,7 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
for (auto it = vec->begin(); it != vec->end(); ++it) {
if (it->get() == search_drawable.get()) {
// #122: Clear the parent before removing
(*it)->setParent(nullptr);
unlink_child(*it);
vec->erase(it);
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
@ -791,7 +743,7 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args)
std::shared_ptr<UIDrawable> drawable = (*vec)[index];
// #122: Clear the parent before removing
drawable->setParent(nullptr);
unlink_child(drawable);
// Remove from vector
vec->erase(vec->begin() + index);
@ -831,6 +783,12 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
return NULL;
}
// #183: Remove from old parent, which may be a scene rather than a drawable.
// Do this BEFORE resolving the index: if the drawable is already a member of
// THIS collection, detaching it erases it from `vec`, and an index clamped
// against the old size could then land one past the end.
drawable->removeFromParent();
// Handle negative indexing and clamping (Python list.insert behavior)
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
if (index < 0) {
@ -842,13 +800,8 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
index = size;
}
// #122: Remove from old parent if it has one
if (auto old_parent = drawable->getParent()) {
drawable->removeFromParent();
}
// #122: Set new parent
drawable->setParent(self->owner.lock());
// #377: parent to the scene, not to a null owner, when this is a scene collection
link_child(self, drawable);
// Insert at position
vec->insert(vec->begin() + index, drawable);

View file

@ -8,6 +8,7 @@
#include "UILine.h"
#include "UICircle.h"
#include "UIArc.h"
#include "3d/Viewport3D.h"
#include "GameEngine.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
@ -942,6 +943,8 @@ void UIDrawable::setParent(std::shared_ptr<UIDrawable> new_parent) {
parent = new_parent;
parent_scene.clear(); // #183: Clear scene parent when setting drawable parent
updatePyIdentityPin(); // #373
// Apply alignment when parent is set (if alignment is configured)
if (new_parent && align_type != AlignmentType::NONE) {
applyAlignment();
@ -952,16 +955,63 @@ void UIDrawable::setParentScene(const std::string& scene_name) {
parent.reset(); // #183: Clear drawable parent when setting scene parent
parent_scene = scene_name;
updatePyIdentityPin(); // #373
// Apply alignment when scene parent is set (if alignment is configured)
if (!scene_name.empty() && align_type != AlignmentType::NONE) {
applyAlignment();
}
}
// #373: hold a strong ref to the Python wrapper while, and only while, this drawable
// is a member of a children collection. See the comment on py_identity in UIDrawable.h.
void UIDrawable::updatePyIdentityPin() {
// Only subclasses carry state worth preserving, and only they can lose anything
// when a wrapper is re-created from the cache miss.
if (!is_python_subclass) return;
const bool in_collection = !parent.expired() || !parent_scene.empty();
if (in_collection) {
if (py_identity) return; // already pinned
if (serial_number == 0 || !Py_IsInitialized()) return;
// The wrapper necessarily exists: is_python_subclass is only ever set from a
// subclass's tp_init, so Python allocated one. A cache miss here would mean the
// wrapper was already collected, and there is nothing left to pin.
py_identity = PythonObjectCache::getInstance().lookup(serial_number); // new ref
} else {
releasePyIdentity();
}
}
void UIDrawable::releaseChildPins(const std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>>& children) {
if (!children) return;
for (auto& child : *children) {
if (child) child->releasePyIdentity();
}
}
void UIDrawable::releasePyIdentity() {
if (!py_identity) return;
// Null the member BEFORE the DECREF: the wrapper's dealloc drops its shared_ptr to
// this drawable, which can re-enter here if that was the last strong ref.
PyObject* tmp = py_identity;
py_identity = nullptr;
if (Py_IsInitialized()) {
Py_DECREF(tmp);
}
}
std::shared_ptr<UIDrawable> UIDrawable::getParent() const {
return parent.lock();
}
// #373: this drops the parent link, so it also drops the Python-identity pin -- which
// may release the last reference to the wrapper, whose dealloc drops the wrapper's
// shared_ptr to this drawable. Callers must therefore hold their own strong ref for the
// duration (every one does: they come through a local shared_ptr from extractDrawable).
// updatePyIdentityPin() is the LAST statement on both paths so that even in that case
// no member of a freed `this` is touched afterwards.
void UIDrawable::removeFromParent() {
// #183: Handle scene parent removal
if (!parent_scene.empty()) {
@ -975,6 +1025,7 @@ void UIDrawable::removeFromParent() {
}
}
parent_scene.clear();
updatePyIdentityPin();
return;
}
@ -1015,6 +1066,7 @@ void UIDrawable::removeFromParent() {
}
parent.reset();
updatePyIdentityPin();
}
// #102 - Global position calculation
@ -1284,6 +1336,133 @@ PyObject* UIDrawable::get_uniforms(PyObject* self, void* closure) {
return (PyObject*)collection;
}
// #369: single cache-aware conversion from a C++ drawable to its Python wrapper.
// Every path that hands a drawable back to Python must go through here, or object
// identity breaks (`x.parent is x.parent` -> False) and Python subclasses are lost.
PyObject* UIDrawable::pyobject_for(std::shared_ptr<UIDrawable> drawable) {
if (!drawable) {
Py_RETURN_NONE;
}
// A live wrapper wins: it carries the caller's subclass and identity.
if (drawable->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(drawable->serial_number);
if (cached) {
return cached; // lookup() already INCREF'd
}
}
PyTypeObject* type = nullptr;
PyObject* obj = nullptr;
switch (drawable->derived_type()) {
case PyObjectsEnum::UIFRAME:
{
type = &mcrfpydef::PyUIFrameType;
auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIFrame>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UICAPTION:
{
type = &mcrfpydef::PyUICaptionType;
auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UICaption>(drawable);
pyObj->font = nullptr;
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UISPRITE:
{
type = &mcrfpydef::PyUISpriteType;
auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UISprite>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRIDVIEW:
{
type = &mcrfpydef::PyUIGridViewType;
auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIGridView>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UILINE:
{
type = &mcrfpydef::PyUILineType;
auto pyObj = (PyUILineObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UILine>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UICIRCLE:
{
type = &mcrfpydef::PyUICircleType;
auto pyObj = (PyUICircleObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UICircle>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIARC:
{
type = &mcrfpydef::PyUIArcType;
auto pyObj = (PyUIArcObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIArc>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIVIEWPORT3D:
{
type = &mcrfpydef::PyViewport3DType;
auto pyObj = (PyViewport3DObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<mcrf::Viewport3D>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
default:
PyErr_SetString(PyExc_TypeError, "Unknown UIDrawable derived type");
return nullptr;
}
// Register the fresh wrapper so the next lookup returns this same object.
// Covers C++-created drawables whose original wrapper was GC'd.
if (obj && drawable->serial_number != 0) {
PyObject* weakref = PyWeakref_NewRef(obj, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(drawable->serial_number, weakref);
Py_DECREF(weakref);
}
}
return obj;
}
// Python API - get parent drawable
PyObject* UIDrawable::get_parent(PyObject* self, void* closure) {
PyObjectsEnum objtype = static_cast<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure));
@ -1299,67 +1478,7 @@ PyObject* UIDrawable::get_parent(PyObject* self, void* closure) {
// Scene not found in python_scenes (shouldn't happen, but fall through to None)
}
auto parent_ptr = drawable->getParent();
if (!parent_ptr) {
Py_RETURN_NONE;
}
// Convert parent to Python object using the cache/conversion system
// Re-use the pattern from UICollection
PyTypeObject* type = nullptr;
PyObject* obj = nullptr;
switch (parent_ptr->derived_type()) {
case PyObjectsEnum::UIFRAME:
{
type = &mcrfpydef::PyUIFrameType;
auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIFrame>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UICAPTION:
{
type = &mcrfpydef::PyUICaptionType;
auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UICaption>(parent_ptr);
pyObj->font = nullptr;
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UISPRITE:
{
type = &mcrfpydef::PyUISpriteType;
auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UISprite>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRIDVIEW:
{
type = &mcrfpydef::PyUIGridViewType;
auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIGridView>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
default:
Py_RETURN_NONE;
}
return obj;
return UIDrawable::pyobject_for(drawable->getParent());
}
// Python API - set parent drawable (or None to remove from parent)

View file

@ -177,9 +177,49 @@ public:
// Remove this drawable from its current parent's children (or scene)
void removeFromParent();
// #373: strong ref to this drawable's Python wrapper, held for exactly as long as
// the drawable is a member of a children collection (a Frame's, a GridView's, or a
// Scene's). PythonObjectCache holds only weakrefs, so without this a subclass
// wrapper that Python stops referencing is collected while C++ still owns the
// object -- and the next .parent / find() / indexing lookup misses the cache and
// hands back a freshly allocated BASE-type wrapper, silently dropping the subclass
// and every attribute the user set on it.
//
// Membership is the boundary, exactly as grid membership is for UIEntity (#266).
// It is represented by the parent link, so the pin is re-evaluated in setParent()
// and setParentScene() -- the two choke points every collection mutator goes
// through. Only subclassed drawables are pinned: a base-type wrapper carries no
// state of its own (the types have no __dict__), so re-creating one is unobservable.
//
// The pin is a reference cycle (wrapper -> shared_ptr -> drawable -> wrapper) that
// Python's GC cannot see through the C++ side, so it MUST be released on the way
// out. Every exit is covered: unlinking calls setParent(nullptr), and an owner
// destroyed while still holding children releases its children's pins first.
PyObject* py_identity = nullptr;
// Acquire or drop py_identity to match the current parent link. Idempotent.
void updatePyIdentityPin();
// Unconditionally drop the pin. Called by an owner tearing down its children vector.
void releasePyIdentity();
// #373: an owner (Frame, GridView, Scene) destroyed while it still holds children
// must drop their pins. Nothing calls setParent(nullptr) on that path -- the vector
// simply dies -- so without this the wrapper <-> drawable cycle would strand the
// entire subtree in memory. Releasing a child's pin cannot free it here: the vector
// still holds a strong ref. It dies with the vector, and its own destructor then
// releases the next level down.
static void releaseChildPins(const std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>>& children);
// Get the global (screen) position by walking up the parent chain (#102)
sf::Vector2f get_global_position() const;
// #369: the single cache-aware C++ -> Python wrapper conversion for drawables.
// Returns the live wrapper for this drawable if one exists (preserving object
// identity and any Python subclass), otherwise allocates one and registers it.
// Returns a new reference, or nullptr with an exception set.
static PyObject* pyobject_for(std::shared_ptr<UIDrawable> drawable);
// Python API for parent/global_position
static PyObject* get_parent(PyObject* self, void* closure);
static int set_parent(PyObject* self, PyObject* value, void* closure);

View file

@ -12,6 +12,40 @@
#include <sstream>
#include <algorithm>
// #369: single cache-aware conversion from a C++ entity to its Python wrapper.
// Raw tp_alloc here is not merely an identity bug as it is for drawables: a duplicate
// wrapper's tp_dealloc unconditionally clears UIEntity::pyobject, destroying the #266
// subclass-identity ref held by the *original* wrapper. Every site must use this.
static PyObject* convertEntityToPython(std::shared_ptr<UIEntity> entity) {
if (!entity) {
Py_RETURN_NONE;
}
if (entity->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(entity->serial_number);
if (cached) {
return cached; // lookup() already INCREF'd
}
}
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
if (!o) return NULL;
o->data = entity;
o->weakreflist = NULL;
if (entity->serial_number != 0) {
PyObject* weakref = PyWeakref_NewRef((PyObject*)o, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(entity->serial_number, weakref);
Py_DECREF(weakref);
}
}
return (PyObject*)o;
}
// ============================================================================
// UIEntityCollectionIter implementation
// ============================================================================
@ -44,23 +78,7 @@ PyObject* UIEntityCollectionIter::next(PyUIEntityCollectionIterObject* self)
auto target = (*self->data)[self->index];
++self->index;
// Check cache first to preserve derived class identity
if (target->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number);
if (cached) {
return cached; // Already INCREF'd by lookup
}
}
// Otherwise create and return a new Python Entity object
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
if (!o) return NULL;
o->data = std::static_pointer_cast<UIEntity>(target);
o->weakreflist = NULL;
return (PyObject*)o;
return convertEntityToPython(std::static_pointer_cast<UIEntity>(target));
}
PyObject* UIEntityCollectionIter::repr(PyUIEntityCollectionIterObject* self)
@ -102,35 +120,7 @@ PyObject* UIEntityCollection::getitem(PyUIEntityCollectionObject* self, Py_ssize
// #329 - O(1) random access (was std::advance over a std::list, O(n))
auto target = (*vec)[index];
// Check cache first to preserve derived class
if (target->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number);
if (cached) {
return cached; // Already INCREF'd by lookup
}
}
// Create a new base Entity object
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
if (!o) return NULL;
o->data = std::static_pointer_cast<UIEntity>(target);
o->weakreflist = NULL;
// Re-register in cache if the entity has a serial number
// This handles the case where the original Python wrapper was GC'd
// but the C++ object persists (e.g., inline-created objects added to collections)
if (target->serial_number != 0) {
PyObject* weakref = PyWeakref_NewRef((PyObject*)o, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(target->serial_number, weakref);
Py_DECREF(weakref);
}
}
return (PyObject*)o;
return convertEntityToPython(std::static_pointer_cast<UIEntity>(target));
}
int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t index, PyObject* value) {
@ -244,19 +234,15 @@ PyObject* UIEntityCollection::concat(PyUIEntityCollectionObject* self, PyObject*
return NULL;
}
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
// Add all elements from self
Py_ssize_t idx = 0;
for (const auto& entity : *self->data) {
auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
PyObject* obj = convertEntityToPython(entity);
if (!obj) {
Py_DECREF(result_list);
return NULL;
}
obj->data = entity;
obj->weakreflist = NULL;
PyList_SET_ITEM(result_list, idx++, (PyObject*)obj);
PyList_SET_ITEM(result_list, idx++, obj);
}
// Add all elements from other
@ -358,21 +344,13 @@ PyObject* UIEntityCollection::subscript(PyUIEntityCollectionObject* self, PyObje
return NULL;
}
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
auto it = self->data->begin();
for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) {
auto cur_it = it;
std::advance(cur_it, cur);
auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
PyObject* obj = convertEntityToPython((*self->data)[cur]);
if (!obj) {
Py_DECREF(result_list);
return NULL;
}
obj->data = *cur_it;
obj->weakreflist = NULL;
PyList_SET_ITEM(result_list, i, (PyObject*)obj);
PyList_SET_ITEM(result_list, i, obj);
}
return result_list;

View file

@ -81,6 +81,7 @@ UIFrame::UIFrame(float _x, float _y, float _w, float _h)
UIFrame::~UIFrame()
{
UIDrawable::releaseChildPins(children); // #373
children.reset();
}

View file

@ -65,6 +65,15 @@ float DijkstraMap::getDistance(int x, int y) const {
return TCOD_dijkstra_get_distance(tcod_dijkstra, x, y);
}
// #375: emit the path in origin->destination order, matching find_path()'s convention
// (excludes the origin, includes the root).
//
// libtcod builds its internal list as [pos, ..., root_adjacent] and
// TCOD_dijkstra_path_walk() pops from the BACK, so walking yields
// [root_adjacent, ..., pos] -- the exact reverse of what path_from's docstring,
// AStarPath.origin/.destination, and step_from all promise. stepFrom() inherited the
// error and returned a cell adjacent to the ROOT rather than to the query position,
// i.e. an arbitrarily long teleport.
std::vector<sf::Vector2i> DijkstraMap::getPathFrom(int x, int y) const {
std::vector<sf::Vector2i> path;
if (!tcod_dijkstra) return path;
@ -75,6 +84,18 @@ std::vector<sf::Vector2i> DijkstraMap::getPathFrom(int x, int y) const {
path.push_back(sf::Vector2i(px, py));
}
}
if (path.empty()) return path; // already at a root, or unreachable
std::reverse(path.begin(), path.end()); // -> [pos, ..., root_adjacent]
path.erase(path.begin()); // drop the origin
// The walk stops one cell short of the root. Append the root this path descends
// into -- via descentStep, so the multi-root case lands on the correct root.
bool ok = false;
sf::Vector2i last = path.empty() ? sf::Vector2i(x, y) : path.back();
sf::Vector2i root_cell = descentStep(last.x, last.y, &ok);
if (ok) path.push_back(root_cell);
return path;
}
@ -84,15 +105,11 @@ sf::Vector2i DijkstraMap::stepFrom(int x, int y, bool* valid) const {
return sf::Vector2i(-1, -1);
}
if (!TCOD_dijkstra_path_set(tcod_dijkstra, x, y)) {
if (valid) *valid = false;
return sf::Vector2i(-1, -1);
}
int px, py;
if (TCOD_dijkstra_path_walk(tcod_dijkstra, &px, &py)) {
// #375: the first cell of the corrected path -- guaranteed adjacent to (x, y).
std::vector<sf::Vector2i> path = getPathFrom(x, y);
if (!path.empty()) {
if (valid) *valid = true;
return sf::Vector2i(px, py);
return path.front();
}
if (valid) *valid = false;

View file

@ -33,6 +33,9 @@ UIGridView::UIGridView()
}
UIGridView::~UIGridView() {
// #373: drop the Python-identity pins of our overlay children before the vector dies
UIDrawable::releaseChildPins(children);
// #362: drop ourselves from the GridData's view registry. This is the only
// place guaranteed to run exactly once per view: tp_dealloc's unregister is
// gated on data.use_count() <= 1 (the #251 pattern), so a view that outlives
@ -289,16 +292,33 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
int y_limit = top_edge + height_sq + 2;
if (y_limit > grid_data->grid_h) y_limit = grid_data->grid_h;
// #341: re-instrument the grid render counters. These were declared and reset
// every frame but never incremented anywhere -- the increments were lost in the
// GridView/chunk refactor, so grid_cells_rendered/entities_rendered/
// grid_render_time have read 0 ever since. Accumulating (not assigning) so that
// two views over one map sum rather than clobber each other.
auto& metrics = Resources::game->metrics;
ScopedAccumTimer gridTimer(metrics.gridRenderTime);
// Cells actually inside the viewport window, per layer drawn.
const int x_start = std::max(0, static_cast<int>(left_edge));
const int y_start = std::max(0, static_cast<int>(top_edge));
const int visible_cells = std::max(0, x_limit - x_start) * std::max(0, y_limit - y_start);
// Render layers below entities (z_index <= 0)
grid_data->sortLayers();
int layers_drawn = 0;
for (auto& layer : grid_data->layers) {
if (layer->z_index > 0) break; // #257: z_index=0 is ground level (below entities)
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
++layers_drawn;
}
// Render entities
if (grid_data->entities) {
ScopedAccumTimer entityTimer(metrics.entityRenderTime);
metrics.totalEntities += static_cast<int>(grid_data->entities->size());
for (auto& e : *grid_data->entities) {
if (e->position.x < left_edge - 2 || e->position.x >= left_edge + width_sq + 2 ||
e->position.y < top_edge - 2 || e->position.y >= top_edge + height_sq + 2) {
@ -310,6 +330,7 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
(e->position.x*cell_width - left_spritepixels + e->sprite_offset.x) * zoom,
(e->position.y*cell_height - top_spritepixels + e->sprite_offset.y) * zoom);
drawent.render(pixel_pos, *activeTexture);
++metrics.entitiesRendered;
}
}
@ -318,8 +339,12 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
if (layer->z_index <= 0) continue; // #257: skip ground-level and below
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
++layers_drawn;
}
// One "cell rendered" per cell per layer drawn -- i.e. cell draw operations.
metrics.gridCellsRendered += visible_cells * layers_drawn;
// Children (grid-world pixel coordinates; owned by this view -- #364)
if (children && !children->empty()) {
if (children_need_sort) {
@ -343,7 +368,9 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
// Perspective overlay (#355: state owned by the view -- see get/set_perspective)
if (perspective_enabled) {
ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime);
// #341: accumulate -- with two perspective views on screen, assignment made
// the second view's time silently replace the first's.
ScopedAccumTimer fovTimer(Resources::game->metrics.fovOverlayTime);
auto entity = perspective_entity.lock();
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));

View file

@ -215,9 +215,37 @@ int run_python_interpreter(const McRogueFaceConfig& config)
else if (!config.exec_scripts.empty()) {
// Execute startup scripts on the existing engine (not in constructor to prevent double-execution)
engine->executeStartupScripts();
if (config.headless) {
engine->setAutoExitAfterExec(true);
// A script that called sys.exit() has already decided the outcome.
if (McRFPy_API::shouldExit()) {
int code = McRFPy_API::exit_code.load();
McRFPy_API::api_shutdown();
delete engine;
return code;
}
// #350: headless --exec that falls off the end without exiting is an error.
//
// step() is the only headless clock, so the run loop cannot advance timers on
// its own: entering it here used to spin forever with a frozen clock, and any
// headless script with a pending timer hung until it was killed. Worse, a
// script that died during setup registered no timers, auto-exited 0, and was
// scored as a passing test.
//
// An --exec script must state its outcome with sys.exit(). If a long-lived
// headless process is genuinely wanted (a server, a REPL host), say so with
// --run-forever.
if (config.headless && !config.run_forever) {
std::cerr << "mcrogueface: --exec scripts finished without calling sys.exit().\n"
<< " In headless mode the engine cannot advance time on its own: "
<< "mcrfpy.step() is the clock.\n"
<< " End the script with sys.exit(0), or pass --run-forever to keep "
<< "the process alive.\n";
McRFPy_API::api_shutdown();
delete engine;
return 1;
}
engine->run();
McRFPy_API::api_shutdown();
delete engine;

View file

@ -8,12 +8,13 @@ Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/cookbook_
In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed!
"""
import mcrfpy
import docs_output
from mcrfpy import automation
import sys
import os
# Output directory - in the docs site images folder
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook"
OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path
# Tile sprites from the labeled tileset
TILES = {

View file

@ -40,6 +40,7 @@ class DemoRunner:
def __init__(self):
self.screens = []
self.menu = None
self.current_index = 0
self.headless = self._detect_headless()
self.screenshot_dir = os.path.join(os.path.dirname(__file__), "screenshots")
@ -65,7 +66,10 @@ class DemoRunner:
def create_menu(self):
"""Create the main menu screen."""
menu = mcrfpy.Scene("menu")
# #372: was a local, but run_interactive() referenced `menu` -- NameError on
# every interactive launch. Keep it on the runner.
self.menu = mcrfpy.Scene("menu")
menu = self.menu
ui = menu.children
# Title
@ -104,46 +108,30 @@ class DemoRunner:
ui.append(instr)
def run_headless(self):
"""Run in headless mode - generate all screenshots."""
print(f"Generating {len(self.screens)} demo screenshots...")
"""Run in headless mode - generate all screenshots.
# Ensure screenshot directory exists
#372/#350: this used to drive a Timer and wait for render frames. Headless
timers only advance under mcrfpy.step(), so that loop never fired. It is also
unnecessary: a screenshot renders the current state synchronously and costs
zero simulation time, so we can just activate each scene and shoot it.
"""
print(f"Generating {len(self.screens)} demo screenshots...")
os.makedirs(self.screenshot_dir, exist_ok=True)
# Use timer to take screenshots after game loop renders each scene
self.current_index = 0
self.render_wait = 0
for i, screen in enumerate(self.screens):
# #372: was `mcrfpy.current_scene = screen` -- a DemoScreen, not a Scene,
# which raised TypeError and broke the headless path outright.
screen.scene.activate()
filename = os.path.join(self.screenshot_dir, screen.get_screenshot_name())
automation.screenshot(filename)
print(f" [{i+1}/{len(self.screens)}] {filename}")
def screenshot_cycle(timer, runtime):
if self.render_wait == 0:
# Set scene and wait for render
if self.current_index >= len(self.screens):
print("Done!")
sys.exit(0)
return
screen = self.screens[self.current_index]
mcrfpy.current_scene = screen
self.render_wait = 1
elif self.render_wait < 2:
# Wait additional frame
self.render_wait += 1
else:
# Take screenshot
screen = self.screens[self.current_index]
filename = os.path.join(self.screenshot_dir, screen.get_screenshot_name())
automation.screenshot(filename)
print(f" [{self.current_index+1}/{len(self.screens)}] {filename}")
self.current_index += 1
self.render_wait = 0
if self.current_index >= len(self.screens):
print("Done!")
sys.exit(0)
self.screenshot_timer = mcrfpy.Timer("screenshot", screenshot_cycle, 50)
print("Done!")
def run_interactive(self):
"""Run in interactive mode with menu."""
self.create_menu()
menu = self.menu
def handle_key(key, state):
if state != mcrfpy.InputState.PRESSED:
@ -156,7 +144,8 @@ class DemoRunner:
if key in _num_key_map:
idx = _num_key_map[key]
if idx < len(self.screens):
mcrfpy.setScene(self.screens[idx].scene_name)
# #372: mcrfpy.setScene() no longer exists.
self.screens[idx].scene.activate()
# ESC returns to menu
elif key == mcrfpy.Key.ESCAPE:
@ -166,14 +155,10 @@ class DemoRunner:
elif key == mcrfpy.Key.Q:
sys.exit(0)
# Register keyboard handler on menu scene
menu.activate()
# The same handler serves the menu and every demo scene, so ESC always works.
menu.on_key = handle_key
# Also register keyboard handler on all demo scenes
for screen in self.screens:
mcrfpy.current_scene = screen
menu.on_key = handle_key
screen.scene.on_key = handle_key
# Start on menu
menu.activate()
@ -185,6 +170,8 @@ def main():
if runner.headless:
runner.run_headless()
# #350: a headless --exec script must declare its outcome.
sys.exit(0)
else:
runner.run_interactive()

40
tests/demo/docs_output.py Normal file
View file

@ -0,0 +1,40 @@
"""Where the showcase scripts write their generated images (#372).
These scripts used to hardcode /opt/goblincorps/repos/mcrogueface.github.io -- an
absolute path outside the repo, on one developer's machine. Anyone else running them
got PermissionError, which is part of why they rotted unnoticed.
Resolution order:
1. $MCRF_DOCS_REPO -- explicit checkout of mcrogueface.github.io
2. ../mcrogueface.github.io -- the usual side-by-side checkout
3. tests/demo/screenshots/ -- in-repo default, always writable
"""
import os
_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO_ROOT = os.path.dirname(os.path.dirname(_HERE))
def docs_repo():
"""Root of the documentation site checkout, or None if there isn't one."""
env = os.environ.get("MCRF_DOCS_REPO")
if env and os.path.isdir(env):
return env
sibling = os.path.join(os.path.dirname(_REPO_ROOT), "mcrogueface.github.io")
if os.path.isdir(sibling):
return sibling
return None
def image_dir(*parts):
"""Directory for generated images, created if needed.
parts: subpath under the docs site's images/ (e.g. "cookbook", "tutorials").
"""
repo = docs_repo()
if repo:
target = os.path.join(repo, "images", *parts)
else:
target = os.path.join(_HERE, "screenshots", *parts)
os.makedirs(target, exist_ok=True)
return target

View file

@ -6,11 +6,12 @@ Generates screenshots for the new API cookbook recipes.
Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/new_features_showcase.py
"""
import mcrfpy
import docs_output
from mcrfpy import automation
import sys
import os
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook"
OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path
def screenshot_alignment():

View file

@ -4,10 +4,12 @@
Uses Frame-based visualization since Grid cell colors use ColorLayer API.
"""
import mcrfpy
import os
import docs_output
from mcrfpy import automation
import sys
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook"
OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path
# Simple PRNG
_seed = 42

View file

@ -10,20 +10,21 @@ class AnimationDemo(DemoScreen):
self.add_title("Animation System")
self.add_description("Smooth property animation with multiple easing functions")
# Create frames to animate
# Create frames to animate.
# #372: easings are the mcrfpy.Easing enum now, not magic strings.
easing_types = [
("linear", mcrfpy.Color(255, 100, 100)),
("easeIn", mcrfpy.Color(100, 255, 100)),
("easeOut", mcrfpy.Color(100, 100, 255)),
("easeInOut", mcrfpy.Color(255, 255, 100)),
("linear", mcrfpy.Easing.LINEAR, mcrfpy.Color(255, 100, 100)),
("easeIn", mcrfpy.Easing.EASE_IN, mcrfpy.Color(100, 255, 100)),
("easeOut", mcrfpy.Easing.EASE_OUT, mcrfpy.Color(100, 100, 255)),
("easeInOut", mcrfpy.Easing.EASE_IN_OUT, mcrfpy.Color(255, 255, 100)),
]
self.frames = []
for i, (easing, color) in enumerate(easing_types):
for i, (easing_name, easing, color) in enumerate(easing_types):
y = 140 + i * 60
# Label
label = mcrfpy.Caption(text=easing, pos=(50, y + 5))
label = mcrfpy.Caption(text=easing_name, pos=(50, y + 5))
label.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(label)
@ -42,9 +43,9 @@ class AnimationDemo(DemoScreen):
# Start animations for each frame (they'll animate when viewed interactively)
for frame, easing in self.frames:
# Animate x to 560 over 2 seconds (starts from current x=150)
anim = mcrfpy.Animation("x", 560.0, 2.0, easing)
anim.start(frame)
# #372: mcrfpy.Animation is no longer exported -- animations are constructed
# by the target itself. Animate x to 560 over 2s (starts from current x=150).
frame.animate("x", 560.0, 2.0, easing)
# Property animations section
prop_frame = mcrfpy.Frame(pos=(50, 400), size=(300, 100))
@ -66,7 +67,7 @@ class AnimationDemo(DemoScreen):
prop_frame.children.append(props_line2)
# Code example - positioned below other elements
code = """# Animation: (property, target, duration, easing)
anim = mcrfpy.Animation("x", 500.0, 2.0, "easeInOut")
anim.start(frame) # Animate frame.x to 500 over 2 seconds"""
code = """# Animate via the target: (property, target, duration, easing)
frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT)
# -> frame.x eases to 500 over 2 seconds"""
self.add_code_example(code, x=50, y=520)

View file

@ -9,8 +9,11 @@ class DemoScreen:
def __init__(self, scene_name):
self.scene_name = scene_name
_scene = mcrfpy.Scene(scene_name)
self.ui = mcrfpy.sceneUI(scene_name)
# #372: mcrfpy.sceneUI() was removed with the pre-Scene-object API. The Scene
# owns its UI collection directly, and the demos need the Scene anyway to
# activate themselves.
self.scene = mcrfpy.Scene(scene_name)
self.ui = self.scene.children
def setup(self):
"""Override to set up the screen content."""

View file

@ -15,7 +15,8 @@ class GridDemo(DemoScreen):
grid.fill_color = mcrfpy.Color(20, 20, 40)
# Add a color layer for the checkerboard pattern (z_index=-1 = below entities)
color_layer = grid.add_layer("color", z_index=-1)
# #372: add_layer() takes a layer OBJECT and no keyword arguments.
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Center camera on middle of grid (in pixel coordinates: cells * cell_size / 2)
# For 15x10 grid with 16x16 cells: center = (15*16/2, 10*16/2) = (120, 80)
@ -28,13 +29,13 @@ class GridDemo(DemoScreen):
point = grid.at(x, y)
# Checkerboard pattern
if (x + y) % 2 == 0:
color_layer.set(x, y, mcrfpy.Color(40, 40, 60))
color_layer.set((x, y), mcrfpy.Color(40, 40, 60))
else:
color_layer.set(x, y, mcrfpy.Color(30, 30, 50))
color_layer.set((x, y), mcrfpy.Color(30, 30, 50))
# Border
if x == 0 or x == 14 or y == 0 or y == 9:
color_layer.set(x, y, mcrfpy.Color(80, 60, 40))
color_layer.set((x, y), mcrfpy.Color(80, 60, 40))
point.walkable = False
# Add some children to the grid
@ -74,7 +75,7 @@ class GridDemo(DemoScreen):
# Code example
code = """# Grid with layers
grid = mcrfpy.Grid(grid_size=(20, 15), pos=(50, 50), size=(320, 240), layers={})
layer = grid.add_layer("color", z_index=-1) # Below entities
layer.set(5, 5, mcrfpy.Color(255, 0, 0)) # Red tile
layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Below entities
layer.set((5, 5), mcrfpy.Color(255, 0, 0)) # Red tile
grid.children.append(mcrfpy.Caption("Label", pos=(80, 48)))"""
self.add_code_example(code, y=420)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

View file

@ -9,12 +9,13 @@ NOTE: In headless mode, automation.screenshot() is SYNCHRONOUS - it renders
and captures immediately. No timer dance needed!
"""
import mcrfpy
import docs_output
from mcrfpy import automation
import sys
import os
# Output
OUTPUT_PATH = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials/part_01_grid_movement.png"
OUTPUT_PATH = os.path.join(docs_output.image_dir("tutorials"), "part_01_grid_movement.png") # #372
# Tile sprites from the labeled tileset
PLAYER_KNIGHT = 84

View file

@ -8,13 +8,14 @@ Usage:
Extracts code from tutorial markdown files and generates screenshots.
"""
import mcrfpy
import docs_output
from mcrfpy import automation
import sys
import os
import re
# Paths
DOCS_REPO = "/opt/goblincorps/repos/mcrogueface.github.io"
DOCS_REPO = docs_output.docs_repo() or os.path.dirname(os.path.abspath(__file__)) # #372
TUTORIAL_DIR = os.path.join(DOCS_REPO, "tutorial")
OUTPUT_DIR = os.path.join(DOCS_REPO, "images", "tutorials")

View file

@ -8,12 +8,13 @@ Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/tutorial_
In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed!
"""
import mcrfpy
import docs_output
from mcrfpy import automation
import sys
import os
# Output directory
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials"
OUTPUT_DIR = docs_output.image_dir("tutorials") # #372: was a hardcoded absolute path
# Tile meanings from the labeled tileset - the FUN sprites!
TILES = {

View file

@ -1,9 +1,19 @@
#!/usr/bin/env python3
"""
A* vs Dijkstra Visual Comparison
=================================
A* vs Dijkstra Comparison
=========================
Shows the difference between A* (single target) and Dijkstra (multi-target).
Verifies that A* (single target) and Dijkstra (multi-target flood) agree on the
cost of the shortest route through an obstacle course, that both produce legal,
contiguous, walkable paths, and that the Dijkstra distance field is consistent
with the paths it hands back.
API notes (updated for the current engine):
* Pathfinding lives on GridData: grid.find_path(start, end) -> AStarPath|None,
grid.get_dijkstra_map(root=...) -> DijkstraMap (.distance(pos), .path_from(pos)).
The old grid.compute_astar_path / compute_dijkstra / get_dijkstra_path are gone.
* GridPoint has no .color -- cell coloring is done with a ColorLayer.
* add_layer() takes a layer OBJECT and no keyword arguments.
"""
import mcrfpy
@ -17,31 +27,41 @@ DIJKSTRA_COLOR = mcrfpy.Color(0, 150, 255) # Blue for Dijkstra
START_COLOR = mcrfpy.Color(255, 100, 100) # Red for start
END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end
GRID_W, GRID_H = 30, 20
# Global state
grid = None
color_layer = None
mode = "ASTAR"
start_pos = (5, 10)
end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall
failures = []
def check(condition, message):
"""Record a failed assertion instead of aborting, so we report every problem."""
if condition:
print(f" PASS: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
def create_map():
"""Create a map with obstacles to show pathfinding differences"""
global grid, color_layer
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
# Create grid
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
# Create grid (view + backing GridData)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(100, 100), size=(600, 400))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (GridPoint.color no longer exists)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize all as floor
for y in range(20):
for x in range(30):
grid.at(x, y).walkable = True
color_layer.set(x, y, FLOOR_COLOR)
for y in range(GRID_H):
for x in range(GRID_W):
grid.grid_data.at(x, y).walkable = True
color_layer.set((x, y), FLOOR_COLOR)
# Create obstacles that make A* and Dijkstra differ
obstacles = [
@ -55,172 +75,179 @@ def create_map():
[(25, y) for y in range(5, 15)],
]
walls = set()
for obstacle_group in obstacles:
for x, y in obstacle_group:
grid.at(x, y).walkable = False
color_layer.set(x, y, WALL_COLOR)
grid.grid_data.at(x, y).walkable = False
color_layer.set((x, y), WALL_COLOR)
walls.add((x, y))
# Mark start and end
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
color_layer.set(start_pos, START_COLOR)
color_layer.set(end_pos, END_COLOR)
def clear_paths():
"""Clear path highlighting"""
for y in range(20):
for x in range(30):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
# Dijkstra maps are cached; walkability just changed, so invalidate.
grid.grid_data.clear_dijkstra_maps()
return walls
# Restore start and end colors
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
def validate_path(cells, label, origin, destination):
"""A path must be contiguous, walkable, and actually arrive at `destination`.
Both find_path() and DijkstraMap.path_from() use the same convention (#375):
the returned path EXCLUDES the origin and ENDS at the destination. The two
differ only in which endpoint is which -- A* is asked start->end, while a
Dijkstra map rooted at start is queried from end, so it walks end->start.
"""
check(len(cells) > 0, f"{label}: path is non-empty")
if not cells:
return
check(cells[-1] == destination,
f"{label}: path ends at {destination} (got {cells[-1]})")
check(origin not in cells, f"{label}: path excludes its origin {origin}")
# The first cell must be a single step from the origin.
prev = origin
contiguous = True
walkable = True
for (x, y) in cells:
dx, dy = abs(x - prev[0]), abs(y - prev[1])
if max(dx, dy) != 1:
contiguous = False
if not grid.grid_data.at(x, y).walkable:
walkable = False
prev = (x, y)
check(contiguous, f"{label}: every step moves exactly one cell (incl. from start)")
check(walkable, f"{label}: every cell on the path is walkable")
def path_cells(path):
return [(int(v.x), int(v.y)) for v in path]
def show_astar():
"""Show A* path"""
clear_paths()
"""Compute + color the A* path. Returns its cells."""
path = grid.grid_data.find_path(start_pos, end_pos)
check(path is not None, "A*: a path exists through the obstacle course")
if path is None:
return []
# Compute A* path
path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
check((int(path.destination.x), int(path.destination.y)) == end_pos,
"A*: .destination is the requested end cell")
# Color the path
for i, (x, y) in enumerate(path):
# NOTE: AStarPath is a *consuming* iterator -- iterating it walks the path,
# so .remaining must be read before draining it.
expected = path.remaining
cells = path_cells(path)
check(expected == len(cells),
f"A*: .remaining ({expected}) matches the iterated length ({len(cells)})")
check(path.remaining == 0, "A*: iterating the path consumes it (.remaining -> 0)")
for (x, y) in cells:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, ASTAR_COLOR)
color_layer.set((x, y), ASTAR_COLOR)
status_text.text = f"A* Path: {len(path)} steps (optimized for single target)"
status_text.text = f"A* Path: {len(cells)} steps (optimized for single target)"
status_text.fill_color = ASTAR_COLOR
return cells
def show_dijkstra():
"""Show Dijkstra exploration"""
clear_paths()
def show_dijkstra(walls):
"""Compute the Dijkstra flood from start, color it, return the path cells."""
dmap = grid.grid_data.get_dijkstra_map(root=start_pos)
check((int(dmap.root.x), int(dmap.root.y)) == start_pos,
"Dijkstra: map root is the start cell")
# Compute Dijkstra from start
grid.compute_dijkstra(start_pos[0], start_pos[1])
# The distance field must be defined on reachable floor and undefined on walls.
check(dmap.distance(start_pos) == 0.0, "Dijkstra: distance to the root itself is 0")
end_dist = dmap.distance(end_pos)
check(end_dist is not None and end_dist > 0,
f"Dijkstra: end cell is reachable (distance={end_dist})")
wall_distances_defined = [w for w in walls if dmap.distance(w) is not None]
check(not wall_distances_defined,
f"Dijkstra: unwalkable cells have no distance ({len(wall_distances_defined)} leaked)")
# Color cells by distance (showing exploration)
max_dist = 40.0
for y in range(20):
for x in range(30):
if grid.at(x, y).walkable:
dist = grid.get_dijkstra_distance(x, y)
for y in range(GRID_H):
for x in range(GRID_W):
if grid.grid_data.at(x, y).walkable:
dist = dmap.distance((x, y))
if dist is not None and dist < max_dist:
# Color based on distance
intensity = int(255 * (1 - dist / max_dist))
color_layer.set(x, y, mcrfpy.Color(0, intensity // 2, intensity))
color_layer.set((x, y), mcrfpy.Color(0, intensity // 2, intensity))
# Get the actual path
path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
# Get the actual path back to the root
cells = path_cells(dmap.path_from(end_pos))
# Highlight the actual path more brightly
for x, y in path:
for (x, y) in cells:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, DIJKSTRA_COLOR)
color_layer.set((x, y), DIJKSTRA_COLOR)
# Restore start and end
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
color_layer.set(start_pos, START_COLOR)
color_layer.set(end_pos, END_COLOR)
status_text.text = f"Dijkstra: {len(path)} steps (explores all directions)"
status_text.text = f"Dijkstra: {len(cells)} steps (explores all directions)"
status_text.fill_color = DIJKSTRA_COLOR
return cells, dmap
def show_both():
"""Show both paths overlaid"""
clear_paths()
def show_both(astar_cells, dijkstra_cells, dmap):
"""Compare the two routes. Different cells are fine; different COST is not."""
print(f" A* : {astar_cells}")
print(f" Dijkstra: {dijkstra_cells}")
# Get both paths
astar_path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
grid.compute_dijkstra(start_pos[0], start_pos[1])
dijkstra_path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
# Both algorithms are optimal, so they must agree on the number of steps
# even if they break ties through different cells.
check(len(astar_cells) == len(dijkstra_cells),
f"A* and Dijkstra agree on path length (A*={len(astar_cells)}, "
f"Dijkstra={len(dijkstra_cells)})")
print(astar_path, dijkstra_path)
# The Dijkstra distance field must be monotonically increasing along the
# A* route - i.e. the flood is consistent with the single-target search.
monotonic = True
prev = dmap.distance(start_pos)
for cell in astar_cells:
d = dmap.distance(cell)
if d is None or d <= prev:
monotonic = False
break
prev = d
check(monotonic, "Dijkstra distance increases at every step along the A* path")
# Color Dijkstra path first (blue)
for x, y in dijkstra_path:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, DIJKSTRA_COLOR)
different_cells = [c for c in dijkstra_cells if c not in astar_cells]
# Then A* path (green) - will overwrite shared cells
for x, y in astar_path:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, ASTAR_COLOR)
# Mark differences
different_cells = []
for cell in dijkstra_path:
if cell not in astar_path:
different_cells.append(cell)
status_text.text = f"Both paths: A*={len(astar_path)} steps, Dijkstra={len(dijkstra_path)} steps"
status_text.text = (f"Both paths: A*={len(astar_cells)} steps, "
f"Dijkstra={len(dijkstra_cells)} steps")
if different_cells:
info_text.text = f"Paths differ at {len(different_cells)} cells"
else:
info_text.text = "Paths are identical"
print(f" {info_text.text}")
def handle_keypress(key_str, state):
"""Handle keyboard input"""
global mode
if state == mcrfpy.InputState.RELEASED: return
print(key_str)
if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q:
print("\nExiting...")
sys.exit(0)
elif key_str == mcrfpy.Key.A or key_str == mcrfpy.Key.NUM_1:
mode = "ASTAR"
show_astar()
elif key_str == mcrfpy.Key.D or key_str == mcrfpy.Key.NUM_2:
mode = "DIJKSTRA"
show_dijkstra()
elif key_str == mcrfpy.Key.B or key_str == mcrfpy.Key.NUM_3:
mode = "BOTH"
show_both()
elif key_str == mcrfpy.Key.SPACE:
# Refresh current mode
if mode == "ASTAR":
show_astar()
elif mode == "DIJKSTRA":
show_dijkstra()
else:
show_both()
# Create the demo
# ---------------------------------------------------------------------------
print("A* vs Dijkstra Pathfinding Comparison")
print("=====================================")
print("Controls:")
print(" A or 1 - Show A* path (green)")
print(" D or 2 - Show Dijkstra (blue gradient)")
print(" B or 3 - Show both paths")
print(" Q/ESC - Quit")
print()
print("A* is optimized for single-target pathfinding")
print("Dijkstra explores in all directions (good for multiple targets)")
print()
create_map()
walls = create_map()
# Set up UI
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
ui = pathfinding_comparison.children
ui.append(grid)
# Scale and position
grid.size = (600, 400) # 30*20, 20*20
grid.pos = (100, 100)
# Add title
title = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add status
status_text = mcrfpy.Caption(pos=(100, 60), text="Press A for A*, D for Dijkstra, B for Both")
status_text = mcrfpy.Caption(pos=(100, 60), text="Comparing A* and Dijkstra")
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
# Add info
info_text = mcrfpy.Caption(pos=(100, 520), text="")
info_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info_text)
# Add legend
legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
@ -229,11 +256,38 @@ legend2 = mcrfpy.Caption(pos=(100, 560), text="Dark=Walls, Light=Floor")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
# Set scene and input
pathfinding_comparison.activate()
pathfinding_comparison.on_key = handle_keypress
# Show initial A* path
show_astar()
# Sanity: the ColorLayer really is the coloring mechanism now.
color_layer.set((0, 0), START_COLOR)
_c = color_layer.at((0, 0))
check((_c.r, _c.g, _c.b) == (255, 100, 100),
"ColorLayer.set/.at round-trips a cell color")
color_layer.set((0, 0), FLOOR_COLOR)
print("\nDemo ready!")
print("\n[A*]")
astar_cells = show_astar()
# A* is asked start -> end.
validate_path(astar_cells, "A*", origin=start_pos, destination=end_pos)
print("\n[Dijkstra]")
dijkstra_cells, dmap = show_dijkstra(walls)
# The Dijkstra map is ROOTED at start_pos and queried FROM end_pos, so its path
# runs end -> start: the origin and destination are the mirror of A*'s.
validate_path(dijkstra_cells, "Dijkstra", origin=end_pos, destination=start_pos)
print("\n[Both]")
show_both(astar_cells, dijkstra_cells, dmap)
# Force a render so the colored comparison is actually drawn.
mcrfpy.automation.screenshot("astar_vs_dijkstra.png")
print()
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,14 +1,30 @@
#!/usr/bin/env python3
"""Debug visibility crash"""
"""Debug visibility crash
Originally probed entity.gridstate / entity.update_visibility() / grid.perspective
for crashes. Updated to the current API (#313/#361):
- entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap:
UNKNOWN / DISCOVERED / VISIBLE, indexed by (x, y))
- grid.perspective -> now takes an Entity or None, not an int index
"""
import mcrfpy
import sys
failures = []
def check(cond, msg):
if cond:
print(f" ok: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
print("Debug visibility...")
# Create scene and grid
debug = mcrfpy.Scene("debug")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5))
# Initialize grid
print("Initializing grid...")
@ -20,39 +36,66 @@ for y in range(5):
# Create entity
print("Creating entity...")
entity = mcrfpy.Entity((2, 2), grid=grid)
entity = mcrfpy.Entity(grid_pos=(2, 2))
entity.sprite_index = 64
print(f"Entity at ({entity.x}, {entity.y})")
grid.entities.append(entity)
# .x/.y are PIXEL coords now; .grid_x/.grid_y are the canonical cell coords
print(f"Entity at cell ({entity.grid_x}, {entity.grid_y}), pixels ({entity.x}, {entity.y})")
check((entity.grid_x, entity.grid_y) == (2, 2), "entity sits at its grid_pos (2, 2)")
# Check gridstate
print(f"\nGridstate length: {len(entity.gridstate)}")
print(f"Expected: {5 * 5}")
# A second entity, so visible_entities() has something to find
neighbor = mcrfpy.Entity(grid_pos=(4, 4))
grid.entities.append(neighbor)
# Try to access gridstate
print("\nChecking gridstate access...")
try:
if len(entity.gridstate) > 0:
state = entity.gridstate[0]
print(f"First state: visible={state.visible}, discovered={state.discovered}")
except Exception as e:
print(f"Error accessing gridstate: {e}")
# Check perspective_map (successor to gridstate)
pmap = entity.perspective_map
print(f"\nPerspective map size: {pmap.size}")
check(pmap.size == (5, 5), "perspective_map covers every grid cell (5x5)")
# Try update_visibility
# Access the map before any FOV is computed: everything is unknown
print("\nChecking perspective_map access...")
Perspective = pmap.enum_type
check(pmap.get((0, 0)) == Perspective.UNKNOWN,
"cells start UNKNOWN before update_visibility()")
check(pmap.histogram() == {int(Perspective.UNKNOWN): 25},
"all 25 cells start UNKNOWN")
# update_visibility must populate the map (open 5x5 room, sight_radius 10)
print("\nTrying update_visibility...")
try:
entity.update_visibility()
print("update_visibility succeeded")
except Exception as e:
print(f"Error in update_visibility: {e}")
entity.update_visibility()
check(pmap.get((2, 2)) == Perspective.VISIBLE,
"entity's own cell is VISIBLE after update_visibility()")
check(pmap.get((0, 0)) == Perspective.VISIBLE,
"far corner of an open, transparent room is VISIBLE")
check(pmap.histogram() == {int(Perspective.VISIBLE): 25},
"all 25 cells of the open room are VISIBLE")
# visible_entities() returns OTHER entities only. Compare by cell, not identity:
# it hands back fresh wrappers rather than cached ones (see notes on UIEntity.cpp:1334).
seen = [(e.grid_x, e.grid_y) for e in entity.visible_entities()]
check(seen == [(4, 4)],
"visible_entities() sees the neighbor at (4, 4) and excludes self")
# Try perspective
# Perspective now takes an Entity (or None), not an index
print("\nTesting perspective...")
print(f"Initial perspective: {grid.perspective}")
check(grid.perspective is None, "grid.perspective defaults to None (omniscient)")
grid.perspective = entity
print(f"Set perspective to entity: {grid.perspective}")
check(grid.perspective is entity, "grid.perspective round-trips the Entity")
grid.perspective = None
check(grid.perspective is None, "grid.perspective can be cleared back to None")
try:
grid.perspective = 0
print(f"Set perspective to 0: {grid.perspective}")
except Exception as e:
print(f"Error setting perspective: {e}")
check(False, "grid.perspective rejects a non-Entity (int index is gone)")
except TypeError:
check(True, "grid.perspective rejects a non-Entity (int index is gone)")
print("\nTest complete")
sys.exit(0)
if failures:
print(f"FAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -5,6 +5,10 @@ Dijkstra Demo - Shows ALL Path Combinations (Including Invalid)
Cycles through every possible entity pair to demonstrate both
valid paths and properly handled invalid paths (empty lists).
Entity 1 is sealed inside a 2x2 pocket of walls, so every path to or from
it must come back as an empty list; the two reachable entities must produce
real, contiguous, walkable paths.
"""
import mcrfpy
@ -20,24 +24,35 @@ NO_PATH_COLOR = mcrfpy.Color(255, 0, 0) # Pure red for unreachable
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
current_combo_index = 0
all_combinations = [] # All possible pairs
current_path = []
failures = []
dijkstra_all = mcrfpy.Scene("dijkstra_all")
def check(condition, message):
"""Record a failed expectation instead of aborting the whole run."""
if not condition:
failures.append(message)
print(f"FAIL: {message}")
return condition
def create_map():
"""Create the map with entities"""
global grid, color_layer, entities, all_combinations
global grid, grid_data, color_layer, entities, all_combinations
dijkstra_all = mcrfpy.Scene("dijkstra_all")
# Create grid
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
# Create grid (grid_size= replaces the old grid_w/grid_h kwargs)
grid = mcrfpy.Grid(grid_size=(14, 10))
grid.fill_color = mcrfpy.Color(0, 0, 0)
grid_data = grid.grid_data
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (GridPoint has no .color anymore)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
# Map layout - Entity 1 is intentionally trapped!
map_layout = [
@ -57,14 +72,14 @@ def create_map():
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
cell = grid_data.at(x, y)
if char == 'W':
cell.walkable = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
cell.walkable = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
entity_positions.append((x, y))
@ -72,38 +87,41 @@ def create_map():
# Create entities
entities = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
print("Map Analysis:")
print("=============")
for i, (x, y) in enumerate(entity_positions):
print(f"Entity {i+1} at ({x}, {y})")
check(len(entities) == 3, f"expected 3 entities from the map, got {len(entities)}")
check(entity_positions == [(10, 2), (6, 4), (0, 5)],
f"unexpected entity positions: {entity_positions}")
# Generate ALL combinations (including invalid ones)
all_combinations = []
for i in range(len(entities)):
for j in range(len(entities)):
if i != j: # Skip self-paths
all_combinations.append((i, j))
print(f"\nTotal path combinations to test: {len(all_combinations)}")
def clear_path_colors():
"""Reset all floor tiles to original color"""
global current_path
for y in range(grid.grid_h):
for x in range(grid.grid_w):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
for y in range(grid_data.grid_h):
for x in range(grid_data.grid_w):
if grid_data.at(x, y).walkable:
color_layer.set((x, y), FLOOR_COLOR)
current_path = []
def show_combination(index):
"""Show a specific path combination (valid or invalid)"""
"""Show a specific path combination (valid or invalid); returns the path"""
global current_combo_index, current_path
current_combo_index = index % len(all_combinations)
@ -116,44 +134,63 @@ def show_combination(index):
e_from = entities[from_idx]
e_to = entities[to_idx]
# Calculate path
path = e_from.path_to(int(e_to.x), int(e_to.y))
# Calculate path (grid_x/grid_y are tile coords; .x/.y are pixels now)
path = e_from.path_to(e_to.grid_x, e_to.grid_y)
current_path = path if path else []
# Always color start and end positions
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
color_layer.set(int(e_to.x), int(e_to.y), NO_PATH_COLOR if not path else END_COLOR)
color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR)
color_layer.set((e_to.grid_x, e_to.grid_y),
NO_PATH_COLOR if not path else END_COLOR)
# Color the path if it exists
if path:
# Color intermediate steps
for i, (x, y) in enumerate(path):
if i > 0 and i < len(path) - 1:
color_layer.set(x, y, PATH_COLOR)
color_layer.set((x, y), PATH_COLOR)
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} Entity {to_idx+1} = {len(path)} steps"
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = {len(path)} steps"
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green for valid
# Show path steps
path_display = []
for i, (x, y) in enumerate(path[:5]):
for (x, y) in path[:5]:
path_display.append(f"({x},{y})")
if len(path) > 5:
path_display.append("...")
path_text.text = "Path: " + " ".join(path_display)
path_text.text = "Path: " + " -> ".join(path_display)
else:
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} Entity {to_idx+1} = NO PATH!"
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = NO PATH!"
status_text.fill_color = mcrfpy.Color(255, 100, 100) # Red for invalid
path_text.text = "Path: [] (No valid path exists)"
# Update info
info_text.text = f"From: Entity {from_idx+1} at ({int(e_from.x)}, {int(e_from.y)}) | To: Entity {to_idx+1} at ({int(e_to.x)}, {int(e_to.y)})"
info_text.text = f"From: Entity {from_idx+1} at ({e_from.grid_x}, {e_from.grid_y}) | To: Entity {to_idx+1} at ({e_to.grid_x}, {e_to.grid_y})"
return path
def validate_path(path, e_from, e_to, label):
"""A valid path must be contiguous, walkable, and connect the endpoints"""
check(path[-1] == (e_to.grid_x, e_to.grid_y),
f"{label}: path does not end at the target: {path[-1]}")
# path_to() excludes the source cell; the first step must be adjacent to it
sx, sy = e_from.grid_x, e_from.grid_y
check((sx, sy) not in path, f"{label}: path should not include the source cell")
check(max(abs(path[0][0] - sx), abs(path[0][1] - sy)) == 1,
f"{label}: first step {path[0]} is not adjacent to source ({sx}, {sy})")
for (x, y) in path:
check(grid_data.at(x, y).walkable,
f"{label}: path crosses unwalkable cell ({x}, {y})")
for (ax, ay), (bx, by) in zip(path, path[1:]):
check(max(abs(ax - bx), abs(ay - by)) == 1,
f"{label}: non-contiguous step ({ax},{ay}) -> ({bx},{by})")
def handle_keypress(key_str, state):
"""Handle keyboard input"""
"""Handle keyboard input (interactive mode only)"""
global current_combo_index
if state == mcrfpy.InputState.RELEASED: return
if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q:
print("\nExiting...")
sys.exit(0)
@ -164,8 +201,8 @@ def handle_keypress(key_str, state):
elif key_str == mcrfpy.Key.R:
show_combination(current_combo_index)
else:
num_keys = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2,
mcrfpy.Key.NUM_4: 3, mcrfpy.Key.NUM_5: 4, mcrfpy.Key.NUM_6: 5}
num_keys = {mcrfpy.Key.Num1: 0, mcrfpy.Key.Num2: 1, mcrfpy.Key.Num3: 2,
mcrfpy.Key.Num4: 3, mcrfpy.Key.Num5: 4, mcrfpy.Key.Num6: 5}
if key_str in num_keys:
combo_num = num_keys[key_str]
if combo_num < len(all_combinations):
@ -214,12 +251,12 @@ controls.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(controls)
# Add legend
legend = mcrfpy.Caption(pos=(120, 560), text="Red Start→Blue End (valid) | Red Start→Red End (invalid)")
legend = mcrfpy.Caption(pos=(120, 560), text="Red Start->Blue End (valid) | Red Start->Red End (invalid)")
legend.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend)
# Expected results info
expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail")
expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1->2, 1->3, 2->1, 3->1 will fail")
expected.fill_color = mcrfpy.Color(255, 150, 150)
ui.append(expected)
@ -227,14 +264,36 @@ ui.append(expected)
dijkstra_all.activate()
dijkstra_all.on_key = handle_keypress
# Show first combination
show_combination(0)
print("\nExpected results:")
print(" Path 1: Entity 1->2 = NO PATH (Entity 1 is trapped)")
print(" Path 2: Entity 1->3 = NO PATH (Entity 1 is trapped)")
print(" Path 3: Entity 2->1 = NO PATH (Entity 1 is trapped)")
print(" Path 4: Entity 2->3 = Valid path")
print(" Path 5: Entity 3->1 = NO PATH (Entity 1 is trapped)")
print(" Path 6: Entity 3->2 = Valid path")
print()
print("\nDemo ready!")
print("Expected results:")
print(" Path 1: Entity 1→2 = NO PATH (Entity 1 is trapped)")
print(" Path 2: Entity 1→3 = NO PATH (Entity 1 is trapped)")
print(" Path 3: Entity 2→1 = NO PATH (Entity 1 is trapped)")
print(" Path 4: Entity 2→3 = Valid path")
print(" Path 5: Entity 3→1 = NO PATH (Entity 1 is trapped)")
print(" Path 6: Entity 3→2 = Valid path")
# Drive every combination and verify the outcome. Entity index 0 is trapped,
# so any pair involving it must yield an empty list; the other pairs must be
# real paths.
for combo_index, (from_idx, to_idx) in enumerate(all_combinations):
path = show_combination(combo_index)
label = f"Entity {from_idx+1}->{to_idx+1}"
if 0 in (from_idx, to_idx):
check(path == [],
f"{label}: expected NO PATH (entity 1 is walled in), got {path}")
print(f" {label}: [] (correctly unreachable)")
else:
if check(bool(path), f"{label}: expected a valid path, got {path}"):
validate_path(path, entities[from_idx], entities[to_idx], label)
print(f" {label}: {len(path)} steps, ends at {path[-1]}")
# Render once so the coloring path is exercised too (rendering costs no sim time)
mcrfpy.automation.screenshot("dijkstra_all_paths.png")
if failures:
print(f"\n{len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -4,6 +4,14 @@ Dijkstra Demo - Cycles Through Different Path Combinations
==========================================================
Shows paths between different entity pairs, skipping impossible paths.
Headless verification of the original demo's claims:
- Entity 1 is walled into a pocket and can reach nobody.
- Entities 2 and 3 can reach each other (both directions), so exactly two
path combinations exist.
- Each generated path is a contiguous chain of walkable cells ending on the
target entity.
- Cycling through the combinations recolors the ColorLayer accordingly.
"""
import mcrfpy
@ -18,24 +26,33 @@ END_COLOR = mcrfpy.Color(100, 100, 255) # Light blue
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
current_path_index = 0
path_combinations = []
current_path = []
failures = []
def check(condition, message):
"""Record a failed assertion without aborting the demo"""
if not condition:
failures.append(message)
print(f" FAIL: {message}")
return condition
def create_map():
"""Create the map with entities"""
global grid, color_layer, entities
global grid, grid_data, color_layer, entities
dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle")
# Create grid
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
# Create grid (grid_size= replaces the old grid_w=/grid_h= kwargs)
grid = mcrfpy.Grid(grid_size=(14, 10))
grid_data = grid.grid_data
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring. Cells no longer have a .color;
# ColorLayer objects are constructed then attached (add_layer takes no kwargs).
color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Map layout
map_layout = [
@ -55,14 +72,14 @@ def create_map():
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
cell = grid_data.at(x, y)
if char == 'W':
cell.walkable = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
cell.walkable = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
entity_positions.append((x, y))
@ -70,58 +87,89 @@ def create_map():
# Create entities
entities = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
print("Entities created:")
for i, (x, y) in enumerate(entity_positions):
print(f" Entity {i+1} at ({x}, {y})")
check(entity_positions == [(10, 2), (6, 4), (0, 5)],
f"expected entities at [(10,2),(6,4),(0,5)], got {entity_positions}")
# Check which entity is trapped
print("\nChecking accessibility:")
reachability = []
for i, e in enumerate(entities):
# Try to path to each other entity
can_reach = []
for j, other in enumerate(entities):
if i != j:
path = e.path_to(int(other.x), int(other.y))
path = e.path_to(other.grid_x, other.grid_y)
if path:
can_reach.append(j+1)
reachability.append(can_reach)
if not can_reach:
print(f" Entity {i+1} at ({int(e.x)}, {int(e.y)}) is TRAPPED!")
print(f" Entity {i+1} at ({e.grid_x}, {e.grid_y}) is TRAPPED!")
else:
print(f" Entity {i+1} can reach entities: {can_reach}")
# Entity 1 sits in a sealed pocket; entities 2 and 3 share the open area.
check(reachability[0] == [], "Entity 1 should be trapped (no reachable entities)")
check(reachability[1] == [3], f"Entity 2 should reach only Entity 3, got {reachability[1]}")
check(reachability[2] == [2], f"Entity 3 should reach only Entity 2, got {reachability[2]}")
# Generate valid path combinations (excluding trapped entity)
global path_combinations
path_combinations = []
# Only paths between entities 2 and 3 (indices 1 and 2) will work
# since entity 1 (index 0) is trapped
if len(entities) >= 3:
# Entity 2 to Entity 3
path = entities[1].path_to(int(entities[2].x), int(entities[2].y))
path = entities[1].path_to(entities[2].grid_x, entities[2].grid_y)
if path:
path_combinations.append((1, 2, path))
# Entity 3 to Entity 2
path = entities[2].path_to(int(entities[1].x), int(entities[1].y))
path = entities[2].path_to(entities[1].grid_x, entities[1].grid_y)
if path:
path_combinations.append((2, 1, path))
print(f"\nFound {len(path_combinations)} valid paths")
check(len(path_combinations) == 2, f"expected 2 valid path combinations, got {len(path_combinations)}")
def validate_path(from_idx, to_idx, path):
"""A path must be a contiguous chain of walkable cells ending on the target"""
e_from = entities[from_idx]
e_to = entities[to_idx]
check(len(path) > 0, f"path {from_idx+1}->{to_idx+1} is empty")
if not path:
return
check(tuple(path[-1]) == (e_to.grid_x, e_to.grid_y),
f"path {from_idx+1}->{to_idx+1} does not end on target: {path[-1]}")
# path_to() excludes the entity's own cell, so walk from the entity position
prev = (e_from.grid_x, e_from.grid_y)
for (x, y) in path:
check(grid_data.at(x, y).walkable, f"path crosses non-walkable cell ({x}, {y})")
step = max(abs(x - prev[0]), abs(y - prev[1]))
check(step == 1, f"path is not contiguous: {prev} -> ({x}, {y})")
prev = (x, y)
def clear_path_colors():
"""Reset all floor tiles to original color"""
global current_path
for y in range(grid.grid_h):
for x in range(grid.grid_w):
cell = grid.at(x, y)
for y in range(grid_data.grid_h):
for x in range(grid_data.grid_w):
cell = grid_data.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
current_path = []
@ -147,16 +195,16 @@ def show_path(index):
current_path = path
if path:
# Color start and end
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
color_layer.set(int(e_to.x), int(e_to.y), END_COLOR)
color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR)
color_layer.set((e_to.grid_x, e_to.grid_y), END_COLOR)
# Color intermediate steps
for i, (x, y) in enumerate(path):
if i > 0 and i < len(path) - 1:
color_layer.set(x, y, PATH_COLOR)
# Color intermediate steps. path_to() excludes the entity's own cell,
# so every element except the last (the target) is an intermediate step.
for (x, y) in path[:-1]:
color_layer.set((x, y), PATH_COLOR)
# Update status
status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} Entity {to_idx+1} ({len(path)} steps)"
status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} ({len(path)} steps)"
# Update path display
path_display = []
@ -164,7 +212,7 @@ def show_path(index):
path_display.append(f"({x},{y})")
if len(path) > 5:
path_display.append("...")
path_text.text = "Path: " + " ".join(path_display) if path_display else "Path: None"
path_text.text = "Path: " + " -> ".join(path_display) if path_display else "Path: None"
def handle_keypress(key_str, state):
"""Handle keyboard input"""
@ -186,6 +234,8 @@ print("==========================")
print("Note: Entity 1 is trapped by walls!")
print()
dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle")
create_map()
# Set up UI
@ -231,9 +281,34 @@ if path_combinations:
else:
status_text.text = "No valid paths! Entity 1 is trapped!"
print("\nDemo ready!")
print("Controls:")
print(" SPACE or N - Next path")
print(" P - Previous path")
print(" R - Refresh current path")
print(" Q - Quit")
# --- Headless verification: cycle every combination the demo would show ---
print("\nCycling paths:")
for i in range(len(path_combinations)):
show_path(i)
from_idx, to_idx, path = path_combinations[current_path_index]
print(f" {status_text.text}")
check(current_path_index == i, f"show_path({i}) selected index {current_path_index}")
validate_path(from_idx, to_idx, path)
# The colored overlay must reflect the displayed path
e_from, e_to = entities[from_idx], entities[to_idx]
check(color_layer.at((e_from.grid_x, e_from.grid_y)) == START_COLOR,
"start cell not colored START_COLOR")
check(color_layer.at((e_to.grid_x, e_to.grid_y)) == END_COLOR,
"end cell not colored END_COLOR")
for (x, y) in path[:-1]:
check(color_layer.at((x, y)) == PATH_COLOR, f"path cell ({x},{y}) not colored PATH_COLOR")
# Cycling wraps around (what SPACE/N does)
show_path(len(path_combinations))
check(current_path_index == 0, "cycling past the last path should wrap to 0")
# Render once to prove the scene is drawable with the layers attached
mcrfpy.automation.screenshot("dijkstra_cycle_paths.png")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -18,40 +18,49 @@ ENTITY_COLORS = [
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
first_point = None
second_point = None
failures = []
WALLS = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)]
def check(condition, message):
"""Record a failed check instead of dying at the first one."""
if not condition:
print(f" FAIL: {message}")
failures.append(message)
return condition
def create_simple_map():
"""Create a simple test map"""
global grid, color_layer, entities
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
global grid, grid_data, color_layer, entities
# Small grid for easy debugging
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Pathfinding + layers live on the shared GridData (#313/#361)
grid_data = grid.grid_data
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid_data.add_layer(color_layer)
print("Initializing 10x10 grid...")
# Initialize all as floor
for y in range(10):
for x in range(10):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
color_layer.set(x, y, FLOOR_COLOR)
grid_data.at(x, y).walkable = True
grid_data.at(x, y).transparent = True
color_layer.set((x, y), FLOOR_COLOR)
# Add a simple wall
print("Adding walls at:")
walls = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)]
for x, y in walls:
for x, y in WALLS:
print(f" Wall at ({x}, {y})")
grid.at(x, y).walkable = False
color_layer.set(x, y, WALL_COLOR)
grid_data.at(x, y).walkable = False
color_layer.set((x, y), WALL_COLOR)
# Create 3 entities
entity_positions = [(2, 5), (8, 5), (5, 8)]
@ -60,7 +69,7 @@ def create_simple_map():
print("\nCreating entities at:")
for i, (x, y) in enumerate(entity_positions):
print(f" Entity {i+1} at ({x}, {y})")
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
@ -70,70 +79,95 @@ def test_path_highlighting():
"""Test path highlighting with debug output"""
print("\n" + "="*50)
print("Testing path highlighting...")
# Select first two entities
e1 = entities[0]
e2 = entities[1]
print(f"\nEntity 1 position: ({e1.x}, {e1.y})")
print(f"Entity 2 position: ({e2.x}, {e2.y})")
# NOTE: entity.x/.y are PIXEL coordinates now; tile coords are grid_x/grid_y.
print(f"\nEntity 1 position: ({e1.grid_x}, {e1.grid_y})")
print(f"Entity 2 position: ({e2.grid_x}, {e2.grid_y})")
# Use entity.path_to()
print("\nCalling entity.path_to()...")
path = e1.path_to(int(e2.x), int(e2.y))
path = e1.path_to(e2.grid_x, e2.grid_y)
print(f"Path returned: {path}")
print(f"Path length: {len(path)} steps")
check(len(path) > 0, "entity.path_to() returned an empty path")
if path:
check(tuple(path[-1]) == (e2.grid_x, e2.grid_y),
f"path does not end at the target: {path[-1]}")
print("\nHighlighting path cells:")
for i, (x, y) in enumerate(path):
print(f" Step {i}: ({x}, {y})")
# Get current color for debugging
cell = grid.at(x, y)
old_c = color_layer.at(x, y)
cell = grid_data.at(x, y)
old_c = color_layer.at((x, y))
old_color = (old_c.r, old_c.g, old_c.b)
# Set new color
color_layer.set(x, y, PATH_COLOR)
new_c = color_layer.at(x, y)
color_layer.set((x, y), PATH_COLOR)
new_c = color_layer.at((x, y))
new_color = (new_c.r, new_c.g, new_c.b)
print(f" Color changed from {old_color} to {new_color}")
print(f" Walkable: {cell.walkable}")
check(cell.walkable, f"path routed through non-walkable cell ({x}, {y})")
check((x, y) not in WALLS, f"path routed through a wall at ({x}, {y})")
# Also test grid's Dijkstra methods
print("\n" + "-"*30)
print("Testing grid Dijkstra methods...")
grid.compute_dijkstra(int(e1.x), int(e1.y))
grid_path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
# compute_dijkstra/get_dijkstra_path/get_dijkstra_distance were replaced by
# GridData.get_dijkstra_map() -> DijkstraMap.path_from()/.distance()
dmap = grid_data.get_dijkstra_map(root=(e1.grid_x, e1.grid_y))
grid_path = list(dmap.path_from((e2.grid_x, e2.grid_y)))
distance = dmap.distance((e2.grid_x, e2.grid_y))
print(f"Grid path: {grid_path}")
print(f"Grid distance: {distance}")
check(distance is not None, "Dijkstra distance to entity 2 is unreachable")
check(len(grid_path) > 0, "Dijkstra path_from() returned an empty path")
for x, y in grid_path:
check(grid_data.at(x, y).walkable,
f"Dijkstra path routed through non-walkable cell ({x}, {y})")
# Dijkstra distance must be at least the straight-line-blocked A* step count
check(distance is None or distance >= len(path) - 0.001,
f"Dijkstra distance {distance} shorter than A* path length {len(path)}")
# Verify colors were set
print("\nVerifying cell colors after highlighting:")
for x, y in path[:3]: # Check first 3 cells
c = color_layer.at(x, y)
c = color_layer.at((x, y))
color = (c.r, c.g, c.b)
expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b)
match = color == expected
print(f" Cell ({x}, {y}): color={color}, expected={expected}, match={match}")
check(match, f"ColorLayer did not retain PATH_COLOR at ({x}, {y})")
def handle_keypress(scene_name, keycode):
# Cells that were never on the path must keep their original color
wall_c = color_layer.at(WALLS[0])
check((wall_c.r, wall_c.g, wall_c.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b),
"wall cell color was clobbered by path highlighting")
def handle_keypress(key, action):
"""Simple keypress handler"""
if keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
if key == mcrfpy.Key.Q or key == mcrfpy.Key.Escape:
print("\nExiting debug...")
sys.exit(0)
elif keycode == 32: # Space
elif key == mcrfpy.Key.Space:
print("\nSpace pressed - retesting path highlighting...")
test_path_highlighting()
# Create the map
print("Dijkstra Debug Test")
print("===================")
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
grid = create_simple_map()
# Initial path test
@ -161,6 +195,17 @@ ui.append(info)
dijkstra_debug.on_key = handle_keypress
dijkstra_debug.activate()
print("\nScene ready. The path should be highlighted in cyan.")
print("If you don't see the path, there may be a rendering issue.")
print("Press SPACE to retest, Q to quit.")
# Render once to prove the highlighted grid actually draws (the original test's
# "if you don't see the path there may be a rendering issue" concern).
mcrfpy.step(0.016)
mcrfpy.automation.screenshot("dijkstra_debug.png")
print("\n" + "="*50)
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,20 +1,26 @@
#!/usr/bin/env python3
"""
Dijkstra Pathfinding Interactive Demo
=====================================
Dijkstra Pathfinding Interactive Demo (headless self-test)
==========================================================
Interactive visualization showing Dijkstra pathfinding between entities.
Visualization + verification of Dijkstra pathfinding between entities.
Controls:
Controls (when run with a window):
- Press 1/2/3 to select the first entity
- Press A/B/C to select the second entity
- Press A/B/C to select the second entity
- Space to clear selection
- Q or ESC to quit
The path between selected entities is automatically highlighted.
The path between selected entities is highlighted on a ColorLayer.
Under --headless the same selection logic is driven programmatically through the
key handler and the resulting paths / distances / highlight colors are asserted.
Entity 1 lives in a sealed room, so it must be unreachable from the others;
entities 2 and 3 are separated by a long wall and must be reachable around it.
"""
import mcrfpy
from mcrfpy import automation
import sys
# Colors - using more distinct values
@ -29,55 +35,65 @@ ENTITY_COLORS = [
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
first_point = None
second_point = None
last_path = None
failures = []
def check(condition, message):
"""Record a failed assertion without aborting the run"""
if not condition:
failures.append(message)
print(f"FAIL: {message}")
return condition
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
def create_map():
"""Create the interactive map with the layout specified by the user"""
global grid, color_layer, entities
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
global grid, grid_data, color_layer, entities
# Create grid - 14x10 as specified
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
grid = mcrfpy.Grid(grid_size=(14, 10))
grid.fill_color = mcrfpy.Color(0, 0, 0)
grid_data = grid.grid_data
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
# Add color layer for cell coloring (GridPoint has no .color anymore)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid_data.add_layer(color_layer)
# Create the map
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
cell = grid_data.at(x, y)
if char == 'W':
# Wall
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
# Floor
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
# Entity position
@ -86,25 +102,32 @@ def create_map():
# Create entities at marked positions
entities = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y))
entity.sprite_index = 49 + i # '1', '2', '3'
grid_data.entities.append(entity)
entities.append(entity)
return grid
def entity_cell(entity):
"""Logical cell of an entity as an (x, y) int tuple"""
cp = entity.cell_pos
return (int(cp.x), int(cp.y))
def clear_path_highlight():
"""Clear any existing path highlighting"""
# Reset all floor tiles to original color
for y in range(grid.grid_h):
for x in range(grid.grid_w):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
for y in range(grid_data.grid_h):
for x in range(grid_data.grid_w):
if grid_data.at(x, y).walkable:
color_layer.set((x, y), FLOOR_COLOR)
def highlight_path():
"""Highlight the path between selected entities"""
"""Highlight the path between selected entities. Returns the path (may be empty)."""
global last_path
last_path = None
if first_point is None or second_point is None:
return
return None
# Clear previous highlighting
clear_path_highlight()
@ -112,72 +135,67 @@ def highlight_path():
# Get entities
entity1 = entities[first_point]
entity2 = entities[second_point]
start = entity_cell(entity1)
end = entity_cell(entity2)
# Compute Dijkstra from first entity
grid.compute_dijkstra(int(entity1.x), int(entity1.y))
# Compute Dijkstra from first entity; walkability is static, but clear the
# cache so each selection recomputes against the current grid.
grid_data.clear_dijkstra_maps()
dijkstra = grid_data.get_dijkstra_map(root=start)
# Get path to second entity
path = grid.get_dijkstra_path(int(entity2.x), int(entity2.y))
distance = dijkstra.distance(end)
path = [] if distance is None else [(int(v.x), int(v.y)) for v in dijkstra.path_from(end)]
if path:
# Highlight the path
for x, y in path:
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, PATH_COLOR)
if grid_data.at(x, y).walkable:
color_layer.set((x, y), PATH_COLOR)
# Also highlight start and end with entity colors
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
color_layer.set(start, ENTITY_COLORS[first_point])
color_layer.set(end, ENTITY_COLORS[second_point])
# Update info
distance = grid.get_dijkstra_distance(int(entity2.x), int(entity2.y))
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps, {distance:.1f} units"
info_text.text = (f"Path: Entity {first_point+1} to Entity {second_point+1} - "
f"{len(path)} steps, {distance:.1f} units")
else:
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
def handle_keypress(scene_name, keycode):
print(f" {info_text.text}")
last_path = path
return path
def handle_keypress(key, action):
"""Handle keyboard input"""
global first_point, second_point
if action != mcrfpy.InputState.PRESSED:
return
# Number keys for first entity
if keycode == 49: # '1'
first_point = 0
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
status_text.text = (f"First: Entity {first_point+1} | "
f"Second: {f'Entity {second_point+1}' if second_point is not None else '?'}")
highlight_path()
elif keycode == 50: # '2'
first_point = 1
status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
elif keycode == 51: # '3'
first_point = 2
status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
# Letter keys for second entity
elif keycode == 65 or keycode == 97: # 'A' or 'a'
second_point = 0
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1"
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key]
status_text.text = (f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | "
f"Second: Entity {second_point+1}")
highlight_path()
elif keycode == 66 or keycode == 98: # 'B' or 'b'
second_point = 1
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2"
highlight_path()
elif keycode == 67 or keycode == 99: # 'C' or 'c'
second_point = 2
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3"
highlight_path()
# Clear selection
elif keycode == 32: # Space
elif key == mcrfpy.Key.SPACE:
first_point = None
second_point = None
clear_path_highlight()
status_text.text = "Press 1/2/3 for first entity, A/B/C for second"
info_text.text = "Space to clear, Q to quit"
# Quit
elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE):
print("\nExiting Dijkstra interactive demo...")
sys.exit(0)
@ -190,6 +208,8 @@ print(" A/B/C - Select second entity")
print(" Space - Clear selection")
print(" Q/ESC - Quit")
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
# Create map
grid = create_map()
@ -221,14 +241,14 @@ legend1 = mcrfpy.Caption(pos=(120, 540), text="Entities: 1=Red 2=Green 3=Blue")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Cyan=Path")
legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Green=Path")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
# Mark entity positions with colored indicators
for i, entity in enumerate(entities):
marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
text=str(i+1))
ex, ey = entity_cell(entity)
marker = mcrfpy.Caption(pos=(120 + ex * 40 + 15, 60 + ey * 40 + 10), text=str(i+1))
marker.fill_color = ENTITY_COLORS[i]
marker.outline = 1
marker.outline_color = mcrfpy.Color(0, 0, 0)
@ -243,4 +263,77 @@ dijkstra_interactive.activate()
print("\nVisualization ready!")
print("Entities are at:")
for i, entity in enumerate(entities):
print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})")
print(f" Entity {i+1}: {entity_cell(entity)}")
# --- Verification: drive the same selection logic the keyboard would ---
check(len(entities) == 3, f"expected 3 entities from map layout, got {len(entities)}")
check(entity_cell(entities[0]) == (10, 2), f"entity 1 at {entity_cell(entities[0])}, expected (10, 2)")
check(entity_cell(entities[1]) == (6, 4), f"entity 2 at {entity_cell(entities[1])}, expected (6, 4)")
check(entity_cell(entities[2]) == (0, 5), f"entity 3 at {entity_cell(entities[2])}, expected (0, 5)")
def press(key):
handle_keypress(key, mcrfpy.InputState.PRESSED)
# Entity 2 -> Entity 3: reachable, but only by going around the long x=2 wall.
print("\nSelect: first=2 (key '2'), second=C (entity 3)")
press(mcrfpy.Key.NUM_2)
press(mcrfpy.Key.C)
path_2_3 = last_path
check(path_2_3,"expected a Dijkstra path between entity 2 (6,4) and entity 3 (0,5)")
if path_2_3:
# The map is ROOTED at the first selection (entity 2) and queried FROM the second
# (entity 3), so the walk runs entity3 -> entity2: per #375 it excludes its origin
# (0,5) and ends at the root (6,4).
check(path_2_3[-1] == (6, 4),
f"path should end at the root, entity 2 (6,4); ended at {path_2_3[-1]}")
check((0, 5) not in path_2_3, "path should exclude its origin, entity 3 (0,5)")
check(all(grid_data.at(x, y).walkable for x, y in path_2_3),
"Dijkstra path crosses a non-walkable cell")
# The wall at x=2 spans rows 1..8, so the path must detour through row 0 or row 9
check(any(y in (0, 9) for x, y in path_2_3),
"path should detour around the x=2 wall via row 0 or row 9")
# Path cells must have been repainted with PATH_COLOR (except the entity endpoints)
mid = path_2_3[len(path_2_3) // 2]
if mid not in ((0, 5), (6, 4)):
c = color_layer.at(*mid)
check((c.r, c.g, c.b) == (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b),
f"path cell {mid} not highlighted with PATH_COLOR, got ({c.r},{c.g},{c.b})")
ec = color_layer.at(0, 5)
check((ec.r, ec.g, ec.b) == (ENTITY_COLORS[2].r, ENTITY_COLORS[2].g, ENTITY_COLORS[2].b),
"destination entity cell not painted with its entity color")
# Entity 1 is sealed inside the walled room: no path in or out.
print("\nSelect: first=1 (key '1'), second=C (entity 3)")
press(mcrfpy.Key.NUM_1)
press(mcrfpy.Key.C)
path_1_3 = last_path
check(not path_1_3, f"entity 1 is walled in; expected no path to entity 3, got {path_1_3}")
check("No path" in info_text.text, f"info text should report no path, got {info_text.text!r}")
grid_data.clear_dijkstra_maps()
d_sealed = grid_data.get_dijkstra_map(root=entity_cell(entities[0])).distance(entity_cell(entities[1]))
check(d_sealed is None, f"distance from sealed entity 1 to entity 2 should be None, got {d_sealed}")
# Space clears the selection and restores floor colors
print("\nSelect: Space (clear)")
press(mcrfpy.Key.SPACE)
check(first_point is None and second_point is None, "Space should clear both selections")
fc = color_layer.at(0, 0)
check((fc.r, fc.g, fc.b) == (FLOOR_COLOR.r, FLOOR_COLOR.g, FLOOR_COLOR.b),
"clear_path_highlight should restore FLOOR_COLOR on walkable cells")
wc = color_layer.at(2, 1)
check((wc.r, wc.g, wc.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b),
"wall cells must keep WALL_COLOR after clearing the path")
# Render once (headless render is free of sim time) to prove the scene draws.
press(mcrfpy.Key.NUM_2)
press(mcrfpy.Key.C)
automation.screenshot("dijkstra_interactive.png")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -1,23 +1,36 @@
#!/usr/bin/env python3
"""
Enhanced Dijkstra Pathfinding Interactive Demo
==============================================
Enhanced Dijkstra Pathfinding Interactive Demo (headless-driven test)
====================================================================
Interactive visualization with entity pathfinding animations.
Controls:
Controls (when run with a window):
- Press 1/2/3 to select the first entity
- Press A/B/C to select the second entity
- Press A/B/C to select the second entity
- Space to clear selection
- M to make selected entity move along path
- P to pause/resume animation
- R to reset entity positions
- Q or ESC to quit
Under --headless --exec the same handlers are driven programmatically and the
resulting paths / animation / reset are asserted (see run_checks() at the bottom).
"""
import mcrfpy
import sys
import math
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
# Colors
WALL_COLOR = mcrfpy.Color(60, 30, 30)
@ -30,6 +43,9 @@ ENTITY_COLORS = [
mcrfpy.Color(100, 100, 255), # Entity 3 - Blue
]
GRID_W = 14
GRID_H = 10
# Global state
grid = None
color_layer = None
@ -42,33 +58,33 @@ animation_progress = 0.0
animation_speed = 2.0 # cells per second
original_positions = [] # Store original entity positions
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2 (entity 1 is sealed inside the room)
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
def create_map():
"""Create the interactive map with the layout specified by the user"""
global grid, color_layer, entities, original_positions
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
# Create grid - 14x10 as specified
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
# Add color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
# Create the map
entity_positions = []
@ -80,12 +96,12 @@ def create_map():
# Wall
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
# Floor
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
# Entity position
@ -95,26 +111,28 @@ def create_map():
entities = []
original_positions = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
original_positions.append((x, y))
return grid
def clear_path_highlight():
"""Clear any existing path highlighting"""
global current_path
# Reset all floor tiles to original color
for y in range(grid.grid_h):
for x in range(grid.grid_w):
for y in range(GRID_H):
for x in range(GRID_W):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
current_path = []
def highlight_path():
"""Highlight the path between selected entities using entity.path_to()"""
global current_path
@ -129,8 +147,8 @@ def highlight_path():
entity1 = entities[first_point]
entity2 = entities[second_point]
# Use the new path_to method!
path = entity1.path_to(int(entity2.x), int(entity2.y))
# Use the path_to method! (entity.x/.y are PIXELS now; cells are grid_x/grid_y)
path = entity1.path_to(entity2.grid_x, entity2.grid_y)
if path:
current_path = path
@ -141,13 +159,13 @@ def highlight_path():
if cell.walkable:
# Use gradient for path visualization
if i < len(path) - 1:
color_layer.set(x, y, PATH_COLOR)
color_layer.set((x, y), PATH_COLOR)
else:
color_layer.set(x, y, VISITED_COLOR)
color_layer.set((x, y), VISITED_COLOR)
# Highlight start and end with entity colors
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
color_layer.set((entity1.grid_x, entity1.grid_y), ENTITY_COLORS[first_point])
color_layer.set((entity2.grid_x, entity2.grid_y), ENTITY_COLORS[second_point])
# Update info
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps"
@ -155,102 +173,96 @@ def highlight_path():
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
current_path = []
def animate_movement(dt):
"""Animate entity movement along path"""
global animation_progress, animating, current_path
if not animating or not current_path or first_point is None:
return
entity = entities[first_point]
# Update animation progress
animation_progress += animation_speed * dt
# Calculate current position along path
path_index = int(animation_progress)
if path_index >= len(current_path):
# Animation complete
animating = False
animation_progress = 0.0
# Snap to final position
# Snap to final position (grid_pos = logical cell, draw_pos = visual)
if current_path:
final_x, final_y = current_path[-1]
entity.x = float(final_x)
entity.y = float(final_y)
entity.grid_pos = (final_x, final_y)
entity.draw_pos = (float(final_x), float(final_y))
return
# Interpolate between path points
if path_index < len(current_path) - 1:
curr_x, curr_y = current_path[path_index]
next_x, next_y = current_path[path_index + 1]
# Calculate interpolation factor
t = animation_progress - path_index
# Smooth interpolation
entity.x = curr_x + (next_x - curr_x) * t
entity.y = curr_y + (next_y - curr_y) * t
# Smooth interpolation (draw_pos is the fractional render position)
entity.draw_pos = (curr_x + (next_x - curr_x) * t,
curr_y + (next_y - curr_y) * t)
entity.grid_pos = (curr_x, curr_y)
else:
# At last point
entity.x, entity.y = current_path[path_index]
entity.grid_pos = current_path[path_index]
entity.draw_pos = (float(current_path[path_index][0]),
float(current_path[path_index][1]))
def handle_keypress(scene_name, keycode):
"""Handle keyboard input"""
def handle_keypress(key, action):
"""Handle keyboard input (#184: handlers receive Key and InputState enums)"""
global first_point, second_point, animating, animation_progress
if action != mcrfpy.InputState.PRESSED:
return
# Number keys for first entity
if keycode == 49: # '1'
first_point = 0
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
status_text.text = f"First: Entity {first_point+1} | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
elif keycode == 50: # '2'
first_point = 1
status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
elif keycode == 51: # '3'
first_point = 2
status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
# Letter keys for second entity
elif keycode == 65 or keycode == 97: # 'A' or 'a'
second_point = 0
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1"
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key]
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity {second_point+1}"
highlight_path()
elif keycode == 66 or keycode == 98: # 'B' or 'b'
second_point = 1
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2"
highlight_path()
elif keycode == 67 or keycode == 99: # 'C' or 'c'
second_point = 2
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3"
highlight_path()
# Movement control
elif keycode == 77 or keycode == 109: # 'M' or 'm'
elif key == mcrfpy.Key.M:
if current_path and first_point is not None:
animating = True
animation_progress = 0.0
control_text.text = "Animation: MOVING (press P to pause)"
# Pause/Resume
elif keycode == 80 or keycode == 112: # 'P' or 'p'
elif key == mcrfpy.Key.P:
animating = not animating
control_text.text = f"Animation: {'MOVING' if animating else 'PAUSED'} (press P to {'pause' if animating else 'resume'})"
# Reset positions
elif keycode == 82 or keycode == 114: # 'R' or 'r'
elif key == mcrfpy.Key.R:
animating = False
animation_progress = 0.0
for i, entity in enumerate(entities):
entity.x, entity.y = original_positions[i]
entity.grid_pos = original_positions[i]
entity.draw_pos = (float(original_positions[i][0]),
float(original_positions[i][1]))
control_text.text = "Entities reset to original positions"
highlight_path() # Re-highlight path after reset
# Clear selection
elif keycode == 32: # Space
elif key == mcrfpy.Key.SPACE:
first_point = None
second_point = None
animating = False
@ -259,16 +271,27 @@ def handle_keypress(scene_name, keycode):
status_text.text = "Press 1/2/3 for first entity, A/B/C for second"
info_text.text = "Space to clear, Q to quit"
control_text.text = "Press M to move, P to pause, R to reset"
# Quit
elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
print("\nExiting enhanced Dijkstra demo...")
sys.exit(0)
# Timer callback for animation
def update_animation(timer, dt):
# Quit
elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE):
print("\nExiting enhanced Dijkstra demo...")
sys.exit(0 if not failures else 1)
# Timer callback for animation. Callback is (timer, runtime_ms) -- runtime is
# cumulative, so derive the frame delta ourselves.
last_runtime_ms = 0.0
timer_ticks = 0
def update_animation(timer, runtime_ms):
"""Update animation state"""
animate_movement(dt / 1000.0) # Convert ms to seconds
global last_runtime_ms, timer_ticks
dt_ms = runtime_ms - last_runtime_ms
last_runtime_ms = runtime_ms
timer_ticks += 1
animate_movement(dt_ms / 1000.0) # Convert ms to seconds
# Create the visualization
print("Enhanced Dijkstra Pathfinding Demo")
@ -282,6 +305,8 @@ print(" R - Reset entity positions")
print(" Space - Clear selection")
print(" Q/ESC - Quit")
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
# Create map
grid = create_map()
@ -324,7 +349,7 @@ ui.append(legend2)
# Mark entity positions with colored indicators
for i, entity in enumerate(entities):
marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
marker = mcrfpy.Caption(pos=(120 + entity.grid_x * 40 + 15, 60 + entity.grid_y * 40 + 10),
text=str(i+1))
marker.fill_color = ENTITY_COLORS[i]
marker.outline = 1
@ -343,4 +368,86 @@ dijkstra_enhanced.activate()
print("\nVisualization ready!")
print("Entities are at:")
for i, entity in enumerate(entities):
print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})")
print(f" Entity {i+1}: ({entity.grid_x}, {entity.grid_y})")
def press(key):
"""Simulate a key press through the scene's real handler"""
handle_keypress(key, mcrfpy.InputState.PRESSED)
def run_checks():
"""Headless driver: exercise the same code paths the keyboard drives."""
print("\n1. Map construction")
check("three entities placed at 'E' markers", len(entities) == 3,
f"got {len(entities)}")
check("entity positions match layout",
original_positions == [(10, 2), (6, 4), (0, 5)],
f"got {original_positions}")
check("walls are not walkable", not grid.at(2, 1).walkable)
check("floors are walkable", grid.at(0, 0).walkable)
# grid.layer(name) returns a wrapper around the same layer (not the same PyObject)
check("color layer attached", grid.layer("color").name == "color")
print("\n2. path_to() between entity 2 and entity 3 (reachable around the wall)")
press(mcrfpy.Key.NUM_2) # first = entity 2 @ (6,4)
press(mcrfpy.Key.C) # second = entity 3 @ (0,5)
check("path found", len(current_path) > 0, "path_to returned empty")
if current_path:
check("path ends at entity 3", tuple(current_path[-1]) == (0, 5),
f"got {current_path[-1]}")
walkable = all(grid.at(x, y).walkable for x, y in current_path)
check("every path cell is walkable", walkable)
contiguous = all(
max(abs(current_path[i+1][0] - current_path[i][0]),
abs(current_path[i+1][1] - current_path[i][1])) == 1
for i in range(len(current_path) - 1))
check("path steps are contiguous", contiguous)
check("info caption reports the path", "Path:" in info_text.text,
info_text.text)
print("\n3. path_to() into the sealed room (entity 1) has no solution")
saved_path = list(current_path)
press(mcrfpy.Key.A) # second = entity 1 @ (10,2), walled in
check("no path reported", len(current_path) == 0,
f"got {len(current_path)} steps")
check("info caption reports no path", "No path" in info_text.text,
info_text.text)
print("\n4. M drives the entity along the path (timer + step())")
press(mcrfpy.Key.C) # back to the reachable target
check("path restored", current_path == saved_path)
press(mcrfpy.Key.M)
check("animating flag set", animating is True)
# mcrfpy.step() is the headless clock; the 16ms timer fires once per step.
for _ in range(400):
mcrfpy.step(0.05)
if not animating:
break
check("animation timer fired", timer_ticks > 0, f"ticks={timer_ticks}")
check("animation completed", animating is False)
entity2 = entities[1]
check("entity 2 arrived at entity 3's cell",
(entity2.grid_x, entity2.grid_y) == (0, 5),
f"got ({entity2.grid_x}, {entity2.grid_y})")
print("\n5. R resets entities to their original positions")
press(mcrfpy.Key.R)
positions = [(e.grid_x, e.grid_y) for e in entities]
check("positions restored", positions == original_positions,
f"got {positions}")
print("\n6. Space clears the selection and the highlight")
press(mcrfpy.Key.SPACE)
check("selection cleared",
first_point is None and second_point is None and current_path == [])
run_checks()
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -4,27 +4,45 @@ Dijkstra Pathfinding Test - Headless
====================================
Tests all Dijkstra functionality and generates a screenshot.
API notes (updated for the current engine):
- Dijkstra maps come from grid.get_dijkstra_map(root=...) -> DijkstraMap.
The old compute_dijkstra / get_dijkstra_distance / get_dijkstra_path
methods are gone; DijkstraMap.distance(pos) and .path_from(pos) replace them.
- GridPoint has no .color; per-cell coloring is done with a ColorLayer.
- Entity.x/.y are pixel coordinates; the logical cell is entity.cell_pos.
- Headless has no clock of its own: mcrfpy.step(dt) drives timers.
"""
import mcrfpy
from mcrfpy import automation
import sys
failures = []
def check(cond, msg):
if not cond:
failures.append(msg)
print(f"FAIL: {msg}")
return cond
def create_test_map():
"""Create a test map with obstacles"""
dijkstra_test = mcrfpy.Scene("dijkstra_test")
# Create grid
grid = mcrfpy.Grid(grid_w=20, grid_h=12)
grid = mcrfpy.Grid(grid_size=(20, 12))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Per-cell coloring now lives on a ColorLayer, not on GridPoint
color_layer = mcrfpy.ColorLayer(name="cells")
grid.add_layer(color_layer)
# Initialize all cells as walkable floor
for y in range(12):
for x in range(20):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
color_layer.set((x, y), mcrfpy.Color(200, 200, 220))
# Add walls to create interesting paths
walls = [
# Vertical wall in the middle
@ -35,11 +53,11 @@ def create_test_map():
# Some scattered obstacles
(5, 2), (15, 2), (5, 9), (15, 9)
]
for x, y in walls:
grid.at(x, y).walkable = False
grid.at(x, y).color = mcrfpy.Color(60, 30, 30)
color_layer.set((x, y), mcrfpy.Color(60, 30, 30))
# Place test entities
entities = []
positions = [(2, 2), (17, 2), (9, 10)]
@ -48,78 +66,125 @@ def create_test_map():
mcrfpy.Color(100, 255, 100), # Green
mcrfpy.Color(100, 100, 255) # Blue
]
for i, (x, y) in enumerate(positions):
entity = mcrfpy.Entity(x, y)
entity = mcrfpy.Entity(grid_pos=(x, y))
entity.sprite_index = 49 + i # '1', '2', '3'
grid.entities.append(entity)
entities.append(entity)
# Mark entity positions
grid.at(x, y).color = colors[i]
return grid, entities
color_layer.set((x, y), colors[i])
def test_dijkstra(grid, entities):
# Walkability was mutated after grid construction; drop any cached maps
grid.clear_dijkstra_maps()
return grid, color_layer, entities, walls
def test_dijkstra(grid, color_layer, entities, walls):
"""Test Dijkstra pathfinding between all entity pairs"""
results = []
for i in range(len(entities)):
for j in range(len(entities)):
if i != j:
# Compute Dijkstra from entity i
e1 = entities[i]
e2 = entities[j]
grid.compute_dijkstra(int(e1.x), int(e1.y))
src = (int(e1.cell_pos.x), int(e1.cell_pos.y))
dst = (int(e2.cell_pos.x), int(e2.cell_pos.y))
dmap = grid.get_dijkstra_map(root=src)
check(dmap.root == mcrfpy.Vector(src[0], src[1]),
f"dijkstra map root should be {src}, got {dmap.root}")
# Get distance and path to entity j
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
distance = dmap.distance(dst)
path = list(dmap.path_from(dst))
check(distance is not None, f"no distance from {src} to {dst}")
check(len(path) > 0, f"no path from {src} to {dst}")
if path:
# Root is reachable and the path is a real, connected walk
check(distance > 0, f"distance {src}->{dst} should be positive")
# Number of steps can't be fewer than the Chebyshev distance
cheb = max(abs(src[0] - dst[0]), abs(src[1] - dst[1]))
check(len(path) >= cheb,
f"path {src}->{dst} too short: {len(path)} < {cheb}")
cells = [(int(v.x), int(v.y)) for v in path]
for cx, cy in cells:
check(grid.at(cx, cy).walkable,
f"path {src}->{dst} crosses unwalkable cell {(cx, cy)}")
check((cx, cy) not in walls,
f"path {src}->{dst} crosses wall {(cx, cy)}")
# Consecutive path cells must be adjacent (8-way)
for a, b in zip(cells, cells[1:]):
check(max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1,
f"path {src}->{dst} jumps from {a} to {b}")
# The map is ROOTED at src and queried FROM dst, so the walk runs
# dst -> src. Per the #375 convention (shared with find_path) it
# EXCLUDES its origin (dst) and ENDS at its destination (src).
check(cells[-1] == src,
f"path from {dst} should end at the root {src}, ended at {cells[-1]}")
check(dst not in cells,
f"path from {dst} should exclude its origin {dst}")
check(max(abs(cells[0][0] - dst[0]), abs(cells[0][1] - dst[1])) == 1,
f"path from {dst} should begin one step away, began at {cells[0]}")
results.append(f"Path {i+1}{j+1}: {len(path)} steps, {distance:.1f} units")
# Color one interesting path
if i == 0 and j == 2: # Path from 1 to 3
for x, y in path[1:-1]: # Skip endpoints
if grid.at(x, y).walkable:
grid.at(x, y).color = mcrfpy.Color(200, 250, 220)
for cx, cy in cells:
if (cx, cy) not in (src, dst) and grid.at(cx, cy).walkable:
color_layer.set((cx, cy), mcrfpy.Color(200, 250, 220))
else:
results.append(f"Path {i+1}{j+1}: No path found!")
# Walls are unreachable: distance must be None, not a bogus number
root = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y))
dmap = grid.get_dijkstra_map(root=root)
check(dmap.distance((10, 4)) is None,
"distance to an unwalkable wall cell should be None")
check(dmap.distance(root) == 0.0,
f"distance from root to itself should be 0, got {dmap.distance(root)}")
# Dijkstra distances are symmetric on a uniform-cost grid
a = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y))
b = (int(entities[1].cell_pos.x), int(entities[1].cell_pos.y))
d_ab = grid.get_dijkstra_map(root=a).distance(b)
d_ba = grid.get_dijkstra_map(root=b).distance(a)
check(abs(d_ab - d_ba) < 0.01,
f"distance should be symmetric: {a}->{b}={d_ab}, {b}->{a}={d_ba}")
return results
def run_test(timer, runtime):
"""Timer callback to run tests and take screenshot"""
# Run pathfinding tests
results = test_dijkstra(grid, entities)
"""Timer callback to run tests"""
global results
results = test_dijkstra(grid, color_layer, entities, walls)
# Update display with results
y_pos = 380
for result in results:
caption = mcrfpy.Caption(result, 50, y_pos)
caption = mcrfpy.Caption(text=result, pos=(50, y_pos))
caption.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(caption)
y_pos += 20
# Take screenshot (one-shot timer)
screenshot_timer = mcrfpy.Timer("screenshot", lambda t, rt: take_screenshot(), 500, once=True)
def take_screenshot():
"""Take screenshot and exit"""
try:
automation.screenshot("dijkstra_test.png")
print("Screenshot saved: dijkstra_test.png")
except Exception as e:
print(f"Screenshot failed: {e}")
# Exit
sys.exit(0)
# Create test map
print("Creating Dijkstra pathfinding test...")
grid, entities = create_test_map()
grid, color_layer, entities, walls = create_test_map()
results = None
# Set up UI
dijkstra_test = mcrfpy.Scene("dijkstra_test")
ui = dijkstra_test.children
ui.append(grid)
@ -133,14 +198,40 @@ title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add legend
legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 13")
legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1->3")
legend.fill_color = mcrfpy.Color(180, 180, 180)
ui.append(legend)
# Set scene
dijkstra_test.activate()
# Run test after scene loads (one-shot timer)
# Run test after scene loads (one-shot timer). Headless has no clock of its own,
# so drive the timer forward explicitly with mcrfpy.step().
test_timer = mcrfpy.Timer("test", run_test, 100, once=True)
print("Running Dijkstra tests...")
for _ in range(10):
mcrfpy.step(0.05)
if results is not None:
break
print("Running Dijkstra tests...")
check(results is not None, "timer callback never fired; Dijkstra tests did not run")
if results:
for line in results:
print(line)
check(len(results) == 6, f"expected 6 entity-pair results, got {len(results)}")
check(all("No path found" not in r for r in results),
"every entity pair should be mutually reachable")
# Rendering is orthogonal to sim time; force a render for the screenshot
try:
automation.screenshot("dijkstra_test.png")
print("Screenshot saved: dijkstra_test.png")
except Exception as e:
check(False, f"Screenshot failed: {e}")
if failures:
print(f"FAILED ({len(failures)} checks)")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,29 +1,67 @@
#!/usr/bin/env python3
"""Force Python to be non-interactive"""
"""Verify that --exec scripts run Python non-interactively.
Historically this file *forced* non-interactive mode (deleting sys.ps1/ps2 and
mangling termios) because the embedded interpreter could drop into a REPL and
hang the engine. That workaround is gone; the property it was protecting is now
an engine guarantee, so this asserts it instead of forcing it:
- the interpreter is not in REPL mode (no sys.ps1 / sys.ps2)
- it will not drop into a REPL after the script (-i / inspect not set)
- stdin is not an interactive terminal, and is not consumed by the engine
- the script, not a prompt, decides when the process ends (#350 exit contract)
"""
import sys
import os
import mcrfpy
print("Attempting to force non-interactive mode...")
failures = []
# Remove ps1/ps2 if they exist
if hasattr(sys, 'ps1'):
delattr(sys, 'ps1')
if hasattr(sys, 'ps2'):
delattr(sys, 'ps2')
# Set environment variable
os.environ['PYTHONSTARTUP'] = ''
def check(label, condition, detail=""):
if condition:
print(" ok : %s" % label)
else:
print(" FAIL : %s %s" % (label, detail))
failures.append(label)
# Try to set stdin to non-interactive
try:
import fcntl
import termios
# Make stdin non-interactive by removing ICANON flag
attrs = termios.tcgetattr(0)
attrs[3] = attrs[3] & ~termios.ICANON
termios.tcsetattr(0, termios.TCSANOW, attrs)
print("Modified terminal attributes")
except:
print("Could not modify terminal attributes")
print("Script complete")
print("Checking non-interactive execution of --exec scripts...")
# 1. REPL prompts only exist when the interpreter is interactive. Their presence
# would mean the engine handed us an interactive interpreter.
check("sys.ps1 absent", not hasattr(sys, "ps1"))
check("sys.ps2 absent", not hasattr(sys, "ps2"))
# 2. Interpreter flags: -i / PYTHONINSPECT would make Python drop to a prompt
# *after* the script finishes, hanging a headless run.
check("sys.flags.interactive == 0", sys.flags.interactive == 0,
"got %r" % (sys.flags.interactive,))
check("sys.flags.inspect == 0", sys.flags.inspect == 0,
"got %r" % (sys.flags.inspect,))
check("PYTHONINSPECT not set in env", not os.environ.get("PYTHONINSPECT"))
# 3. stdin must not be an interactive terminal driving a REPL.
# NOTE: deliberately no blocking read here -- under a test runner stdin is an
# open pipe that never sends EOF, so sys.stdin.read() would hang forever. The
# thing worth asserting is that no prompt is bound to it.
check("stdin is not a tty", sys.stdin is None or not sys.stdin.isatty())
# 4. The engine must not be running its own read-eval-print loop over our script:
# module scope is ordinary script scope, and the headless clock only advances
# when we ask it to (#350). One step() must not spin into an interactive loop.
scene = mcrfpy.Scene("force_non_interactive")
mcrfpy.current_scene = scene
fired = []
mcrfpy.Timer("tick", lambda timer, runtime: fired.append(runtime), 50)
for _ in range(4):
mcrfpy.step(0.05)
check("headless clock advances under script control", len(fired) >= 1,
"timer fired %d time(s)" % len(fired))
if failures:
print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures)))
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,40 +1,62 @@
#!/usr/bin/env python3
"""
Interactive Visibility Demo
==========================
Interactive Visibility Test
===========================
Controls:
- WASD: Move the player (green @)
- Arrow keys: Move enemy (red E)
- Tab: Cycle perspective (Omniscient Player Enemy Omniscient)
- Space: Update visibility for current entity
- R: Reset positions
Originally an interactive demo (WASD moves the player, arrows move the enemy,
Tab cycles perspective, Space recomputes visibility, R resets). Headless mode
never delivers real keystrokes, so the same key handler is now driven with
mcrfpy.automation.keyDown/keyUp and the resulting state is asserted.
Covers:
- walls block movement (walkable) and sight (transparent)
- Entity.update_visibility() / Entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE)
- moving an entity discovers new cells and demotes VISIBLE -> DISCOVERED
- Grid.perspective cycling (None -> player -> enemy -> None)
API notes (current contract):
- grid.add_layer() takes a layer OBJECT, no kwargs; GridPoint has no .color,
so cell coloring goes through a ColorLayer.
- Grid.perspective is an Entity (or None), not an integer index.
"""
import mcrfpy
from mcrfpy import automation
import sys
GRID_W, GRID_H = 30, 20
failures = []
def check(cond, msg):
if not cond:
failures.append(msg)
print(f"FAIL: {msg}")
return cond
# Create scene and grid
visibility_demo = mcrfpy.Scene("visibility_demo")
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(50, 100), size=(900, 600))
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (GridPoint.color no longer exists)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize grid - all walkable and transparent
for y in range(20):
for x in range(30):
for y in range(GRID_H):
for x in range(GRID_W):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color
color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Floor color
# Create walls
walls = [
# Central cross
[(15, y) for y in range(8, 12)],
[(x, 10) for x in range(13, 18)],
# Central cross - a solid wall separating left half from right half
[(15, y) for y in range(0, GRID_H)],
# Rooms
# Top-left room
@ -54,33 +76,42 @@ walls = [
[(28, y) for y in range(15, 18)] + [(x, 18) for x in range(22, 28)],
]
wall_cells = set()
for wall_group in walls:
for x, y in wall_group:
if 0 <= x < 30 and 0 <= y < 20:
if 0 <= x < GRID_W and 0 <= y < GRID_H:
cell = grid.at(x, y)
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color
color_layer.set((x, y), mcrfpy.Color(40, 20, 20)) # Wall color
wall_cells.add((x, y))
# Create entities
player = mcrfpy.Entity((5, 10), grid=grid)
player = mcrfpy.Entity(grid_pos=(5, 10))
player.sprite_index = 64 # @
enemy = mcrfpy.Entity((25, 10), grid=grid)
grid.grid_data.entities.append(player)
enemy = mcrfpy.Entity(grid_pos=(25, 10))
enemy.sprite_index = 69 # E
grid.grid_data.entities.append(enemy)
# Short FOV radius: the default (10) is wide enough that the player's start cell
# stays in FOV from the far side of the room, so cells could never demote from
# VISIBLE to DISCOVERED. update_visibility() reads GridData.fov_radius (Entity.
# sight_radius only feeds the TARGET trigger).
grid.grid_data.fov_radius = 4
# Update initial visibility
player.update_visibility()
enemy.update_visibility()
# Global state
current_perspective = -1
# Global state: perspective is now an Entity | None, not an int index
perspectives = [None, player, enemy]
perspective_names = ["Omniscient", "Player", "Enemy"]
current_perspective = 0
# UI Setup
ui = visibility_demo.children
ui.append(grid)
grid.pos = (50, 100)
grid.size = (900, 600) # 30*30, 20*30
# Title
title = mcrfpy.Caption(pos=(350, 20), text="Interactive Visibility Demo")
@ -92,10 +123,6 @@ perspective_label = mcrfpy.Caption(pos=(50, 50), text="Perspective: Omniscient")
perspective_label.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(perspective_label)
controls = mcrfpy.Caption(pos=(50, 730), text="WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset")
controls.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(controls)
player_info = mcrfpy.Caption(pos=(700, 50), text="Player: (5, 10)")
player_info.fill_color = mcrfpy.Color(100, 255, 100)
ui.append(player_info)
@ -104,41 +131,41 @@ enemy_info = mcrfpy.Caption(pos=(700, 70), text="Enemy: (25, 10)")
enemy_info.fill_color = mcrfpy.Color(255, 100, 100)
ui.append(enemy_info)
# Helper functions
def move_entity(entity, dx, dy):
"""Move entity if target is walkable"""
new_x = int(entity.x + dx)
new_y = int(entity.y + dy)
if 0 <= new_x < 30 and 0 <= new_y < 20:
new_x = int(entity.grid_x + dx)
new_y = int(entity.grid_y + dy)
if 0 <= new_x < GRID_W and 0 <= new_y < GRID_H:
cell = grid.at(new_x, new_y)
if cell.walkable:
entity.x = new_x
entity.y = new_y
entity.grid_pos = (new_x, new_y)
entity.update_visibility()
return True
return False
def update_info():
"""Update info displays"""
player_info.text = f"Player: ({int(player.x)}, {int(player.y)})"
enemy_info.text = f"Enemy: ({int(enemy.x)}, {int(enemy.y)})"
player_info.text = f"Player: ({player.grid_x}, {player.grid_y})"
enemy_info.text = f"Enemy: ({enemy.grid_x}, {enemy.grid_y})"
def cycle_perspective():
"""Cycle through perspectives"""
"""Cycle through perspectives: Omniscient -> Player -> Enemy -> Omniscient"""
global current_perspective
# Cycle: -1 → 0 → 1 → -1
current_perspective = (current_perspective + 2) % 3 - 1
grid.perspective = current_perspective
name = perspective_names[current_perspective + 1]
perspective_label.text = f"Perspective: {name}"
current_perspective = (current_perspective + 1) % 3
grid.perspective = perspectives[current_perspective]
perspective_label.text = f"Perspective: {perspective_names[current_perspective]}"
# Key handlers
def handle_keys(key, state):
"""Handle keyboard input"""
if state == mcrfpy.InputState.RELEASED: return
if state == mcrfpy.InputState.RELEASED:
return
# Player movement (WASD)
if key == mcrfpy.Key.W:
move_entity(player, 0, -1)
@ -167,37 +194,120 @@ def handle_keys(key, state):
elif key == mcrfpy.Key.SPACE:
player.update_visibility()
enemy.update_visibility()
print("Updated visibility for both entities")
# R to reset
elif key == mcrfpy.Key.R:
player.x, player.y = 5, 10
enemy.x, enemy.y = 25, 10
player.grid_pos = (5, 10)
enemy.grid_pos = (25, 10)
player.update_visibility()
enemy.update_visibility()
update_info()
print("Reset positions")
# Q to quit
elif key == mcrfpy.Key.Q:
print("Exiting...")
sys.exit(0)
update_info()
# Set scene first
visibility_demo.activate()
# Register key handler (operates on current scene)
# Set scene first, then register key handler
visibility_demo.activate()
visibility_demo.on_key = handle_keys
print("Interactive Visibility Demo")
print("===========================")
print("WASD: Move player (green @)")
print("Arrows: Move enemy (red E)")
print("Tab: Cycle perspective")
print("Space: Update visibility")
print("R: Reset positions")
print("Q: Quit")
print("\nCurrent perspective: Omniscient (shows all)")
print("Try moving entities and switching perspectives!")
def press(key):
"""Deliver a real key event to the scene handler."""
automation.keyDown(key)
automation.keyUp(key)
UNKNOWN = mcrfpy.Perspective.UNKNOWN
DISCOVERED = mcrfpy.Perspective.DISCOVERED
VISIBLE = mcrfpy.Perspective.VISIBLE
def seen(entity, x, y):
return entity.perspective_map.get(x, y)
def main():
# --- Initial visibility: player sees its own cell, and cannot see through
# the solid wall at x=15 into the enemy's half of the map.
check(seen(player, 5, 10) == VISIBLE, "player should see its own cell")
check(seen(player, 25, 10) == UNKNOWN,
f"player should not see across the wall; got {seen(player, 25, 10)}")
check(seen(enemy, 25, 10) == VISIBLE, "enemy should see its own cell")
check(seen(enemy, 5, 10) == UNKNOWN,
f"enemy should not see across the wall; got {seen(enemy, 5, 10)}")
check(player not in enemy.visible_entities(),
"wall should hide the player from the enemy")
# --- WASD moves the player; cells it leaves behind stay DISCOVERED.
press('d')
check((player.grid_x, player.grid_y) == (6, 10),
f"'d' should move player right; got {(player.grid_x, player.grid_y)}")
press('s')
check((player.grid_x, player.grid_y) == (6, 11),
f"'s' should move player down; got {(player.grid_x, player.grid_y)}")
check(player_info.text == "Player: (6, 11)", f"caption not updated: {player_info.text}")
# --- Walls block movement: walk the player into the central wall.
while player.grid_x < 14:
before = player.grid_x
press('d')
check(player.grid_x == before + 1, "player should walk freely up to the wall")
check((player.grid_x, player.grid_y) == (14, 11), "player should be adjacent to the wall")
press('d')
check((player.grid_x, player.grid_y) == (14, 11),
f"wall must block movement; player reached {(player.grid_x, player.grid_y)}")
# --- Having walked across the room, previously-seen cells are remembered
# but no longer visible.
check(seen(player, 5, 10) == DISCOVERED,
f"start cell should be remembered, not visible; got {seen(player, 5, 10)}")
check(seen(player, 14, 11) == VISIBLE, "player's current cell should be visible")
check(seen(player, 25, 10) == UNKNOWN, "the wall still hides the enemy's half")
# --- Arrow keys move the enemy.
press('left')
check((enemy.grid_x, enemy.grid_y) == (24, 10),
f"left arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}")
press('up')
check((enemy.grid_x, enemy.grid_y) == (24, 9),
f"up arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}")
# --- Space recomputes visibility without moving anything.
pos_before = (player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y)
press('space')
check((player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y) == pos_before,
"space must not move entities")
check(seen(player, 14, 11) == VISIBLE, "player still sees its own cell after space")
# --- Tab cycles perspective: Omniscient -> Player -> Enemy -> Omniscient.
check(grid.perspective is None, "grid starts omniscient")
press('tab')
check(grid.perspective is player, f"tab 1 -> player; got {grid.perspective}")
check(perspective_label.text == "Perspective: Player", perspective_label.text)
press('tab')
check(grid.perspective is enemy, f"tab 2 -> enemy; got {grid.perspective}")
press('tab')
check(grid.perspective is None, f"tab 3 -> omniscient; got {grid.perspective}")
check(perspective_label.text == "Perspective: Omniscient", perspective_label.text)
# --- R resets positions and visibility.
press('r')
check((player.grid_x, player.grid_y) == (5, 10), "R resets the player")
check((enemy.grid_x, enemy.grid_y) == (25, 10), "R resets the enemy")
check(seen(player, 5, 10) == VISIBLE, "player sees its cell again after reset")
check(seen(player, 14, 11) == DISCOVERED,
f"cells left behind stay remembered after reset; got {seen(player, 14, 11)}")
# --- Perspective rendering path must not crash.
grid.perspective = player
automation.screenshot("interactive_visibility.png")
grid.perspective = None
if failures:
print(f"FAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -4,15 +4,28 @@
import mcrfpy
import sys
failures = []
def check(cond, msg):
if cond:
print(f" ok: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
# Create scene and grid
print("Creating scene...")
vis_test = mcrfpy.Scene("vis_test")
print("Creating grid...")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(300, 300))
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# (GridPoint has no .color anymore; per-cell color lives in a ColorLayer)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
check(grid.layer("color") is not None, "color layer attached to grid")
check(tuple(color_layer.grid_size) == (10, 10), "color layer auto-resized to grid")
# Initialize grid
print("Initializing grid...")
@ -21,29 +34,61 @@ for y in range(10):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
_c = color_layer.at(3, 7)
check((_c.r, _c.g, _c.b) == (100, 100, 120), "color layer stores per-cell color")
# An opaque wall so visibility has something to actually occlude
for y in range(10):
wall = grid.at(2, y)
wall.transparent = False
wall.walkable = False
# Create entity
print("Creating entity...")
entity = mcrfpy.Entity((5, 5), grid=grid)
entity = mcrfpy.Entity(grid_pos=(5, 5))
entity.sprite_index = 64
grid.entities.append(entity)
check(entity.grid is grid.grid_data, "entity.grid is the shared GridData (#313/#361)")
print("Updating visibility...")
entity.update_visibility()
# entity.gridstate -> entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE)
pmap = entity.perspective_map
check(pmap.get((5, 5)) == mcrfpy.Perspective.VISIBLE, "entity's own cell is VISIBLE")
check(pmap.get((9, 5)) == mcrfpy.Perspective.VISIBLE, "open cell on entity's side is VISIBLE")
check(pmap.get((0, 5)) == mcrfpy.Perspective.UNKNOWN, "cell behind the wall is UNKNOWN")
check(grid.is_in_fov((5, 5)), "grid.is_in_fov agrees the entity's cell is lit")
# Set up UI
print("Setting up UI...")
ui = vis_test.children
ui.append(grid)
grid.pos = (50, 50)
grid.size = (300, 300)
check(len(ui) == 1, "grid appended to scene UI")
# Test perspective
# perspective is now an Entity (fog-of-war source) or None (omniscient),
# not the old integer entity index (-1 == omniscient).
print("Testing perspective...")
grid.perspective = -1 # Omniscient
grid.perspective = entity
check(grid.perspective is entity, "perspective bound to entity")
check(grid.perspective_enabled, "perspective mode enabled")
grid.perspective = None # Omniscient
print(f"Perspective set to: {grid.perspective}")
check(grid.perspective is None, "perspective cleared")
check(not grid.perspective_enabled, "omniscient: perspective mode disabled")
print("Setting scene...")
vis_test.activate()
check(mcrfpy.current_scene is vis_test, "scene activated")
print("Ready!")
print("Ready!")
if failures:
print(f"FAIL ({len(failures)} checks failed)")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,39 +1,87 @@
#!/usr/bin/env python3
"""Simple visibility test without entity append"""
"""Simple visibility test without entity append
Original intent: verify an Entity associated with a Grid has per-entity visibility
memory that is initialized, that entity.at(x, y) reports per-cell visibility state,
and that entity.update_visibility() recomputes it.
API updates (current contract):
- entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap:
UNKNOWN / DISCOVERED / VISIBLE, lazily allocated once the entity has a grid)
- the per-cell object no longer carries .visible / .discovered; entity.at(x, y)
returns a GridPoint only when the cell is currently visible, else None.
Discovered-but-not-visible cells are read from perspective_map directly.
- Entity(grid_pos=...) + grid.entities.append(entity) replaces Entity((x,y), grid=grid)
"""
import mcrfpy
import sys
print("Simple visibility test...")
failures = []
def check(label, condition):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label}")
failures.append(label)
# Create scene and grid
simple = mcrfpy.Scene("simple")
print("Scene created")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5))
print("Grid created")
# Column x == 1 is an opaque wall; everything else is open floor.
for x in range(5):
for y in range(5):
point = grid.at(x, y)
point.walkable = True
point.transparent = (x != 1)
# Create entity with grid association
entity = mcrfpy.Entity((2, 2), grid=grid)
print(f"Entity created at ({entity.x}, {entity.y})")
entity = mcrfpy.Entity(grid_pos=(3, 2))
grid.entities.append(entity)
print(f"Entity created at {entity.cell_pos}")
# Check if gridstate is initialized
print(f"Gridstate length: {len(entity.gridstate)}")
# Check that per-entity visibility memory is initialized
state_map = entity.perspective_map
print(f"Perspective map size: {state_map.size}")
check("perspective_map allocated", state_map is not None)
check("perspective_map matches grid size", tuple(state_map.size) == (5, 5))
check("perspective_map starts all UNKNOWN", state_map.count(mcrfpy.Perspective.UNKNOWN) == 25)
# Try to access at method
try:
state = entity.at(0, 0)
print(f"at(0,0) returned: {state}")
print(f"visible: {state.visible}, discovered: {state.discovered}")
except Exception as e:
print(f"Error in at(): {e}")
# at() before any FOV computation: nothing is visible yet
check("at(0, 0) is None before update_visibility", entity.at(0, 0) is None)
# Try update_visibility
try:
entity.update_visibility()
print("update_visibility() succeeded")
except Exception as e:
print(f"Error in update_visibility(): {e}")
# Compute visibility from (3, 2)
entity.update_visibility()
print("update_visibility() succeeded")
check("own cell is VISIBLE", state_map[3, 2] == mcrfpy.Perspective.VISIBLE)
check("at(3, 2) returns the visible GridPoint", entity.at(3, 2) is not None)
check("cell behind the wall stays UNKNOWN", state_map[0, 2] == mcrfpy.Perspective.UNKNOWN)
check("at(0, 2) is None (occluded)", entity.at(0, 2) is None)
# Move across the wall: previously seen cells must be remembered as DISCOVERED,
# not still reported as visible.
entity.grid_pos = (0, 2)
entity.update_visibility()
print(f"Entity moved to {entity.cell_pos}")
check("new cell is VISIBLE", state_map[0, 2] == mcrfpy.Perspective.VISIBLE)
check("old cell demoted to DISCOVERED", state_map[3, 2] == mcrfpy.Perspective.DISCOVERED)
check("at(3, 2) is None once occluded again", entity.at(3, 2) is None)
if failures:
print(f"Test complete - {len(failures)} FAILURE(S): {failures}")
sys.exit(1)
print("Test complete")
sys.exit(0)
print("PASS")
sys.exit(0)

View file

@ -1,23 +1,107 @@
#!/usr/bin/env python3
"""Trace interactive mode by monkey-patching"""
"""Tripwire test: the engine must never consult the REPL prompt while running.
Originally this was a debugging aid -- it monkey-patched sys.ps1 with an object
that printed a stack trace when accessed, then did "nothing else, let the game
run", so a human could see whether the engine dropped into an interactive REPL.
It never asserted anything and never declared an exit status.
The tripwire is still the right instrument; it just needs to be checked. sys.ps1
is only read by a read-eval-print loop, so any access to it during an --exec run
means the embedded interpreter fell into interactive mode (which, headless, hangs
the process on stdin). This installs the detector, drives a full headless run
past it (#350: mcrfpy.step() is the clock; screenshot() forces a render), and
fails if the tripwire was touched.
Sibling force_non_interactive.py asserts the *static* non-interactive properties
(flags, absent prompts, stdin); this one watches the *running* engine.
"""
import sys
import os
import traceback
import mcrfpy
# Monkey-patch to detect interactive mode
original_ps1 = None
if hasattr(sys, 'ps1'):
original_ps1 = sys.ps1
failures = []
accesses = []
def check(label, condition, detail=""):
if condition:
print(" ok : %s" % label)
else:
print(" FAIL : %s %s" % (label, detail))
failures.append(label)
class PS1Detector:
def __repr__(self):
import traceback
print("\n!!! sys.ps1 accessed! Stack trace:")
"""Screams (and records) if anything asks for the interactive prompt."""
def _trip(self, how):
accesses.append(how)
print("\n!!! sys.ps1 accessed via %s! Stack trace:" % how)
traceback.print_stack()
return ">>> "
# Set our detector
sys.ps1 = PS1Detector()
print("Trace script loaded, ps1 detector installed")
def __repr__(self):
return self._trip("__repr__")
# Do nothing else - let the game run
def __str__(self):
return self._trip("__str__")
# The engine must not have installed a prompt of its own before we get here.
check("sys.ps1 not preinstalled by engine", not hasattr(sys, "ps1"))
detector = PS1Detector()
sys.ps1 = detector
print("ps1 detector installed")
# Now let the engine actually run. In headless mode the engine advances only when
# we drive it, so "letting the game run" means stepping the clock ourselves and
# forcing a render -- both of which are the paths that historically could reenter
# the interpreter.
scene = mcrfpy.Scene("trace_interactive")
mcrfpy.current_scene = scene
ticks = []
mcrfpy.Timer("tick", lambda timer, runtime: ticks.append(runtime), 50)
frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
scene.children.append(frame)
frame.animate("x", 200.0, 0.2, mcrfpy.Easing.EASE_IN_OUT)
for _ in range(10):
mcrfpy.step(0.05)
shot = "trace_interactive_render.png"
mcrfpy.automation.screenshot(shot)
if os.path.exists(shot):
os.remove(shot)
# The run must have been real, or the tripwire proves nothing.
check("headless clock advanced (timer fired)", len(ticks) >= 1,
"timer fired %d time(s)" % len(ticks))
check("animation advanced during run", frame.x > 10.0,
"frame.x = %r" % (frame.x,))
# The tripwire itself: nothing may have read sys.ps1 during any of that.
check("sys.ps1 never accessed during engine run", not accesses,
"accessed %d time(s): %s" % (len(accesses), accesses))
# And the engine must not have reconfigured the interpreter behind our back --
# our detector should still be the installed prompt, and inspect mode still off.
check("sys.ps1 still our detector", sys.ps1 is detector)
check("sys.flags.inspect == 0", sys.flags.inspect == 0,
"got %r" % (sys.flags.inspect,))
check("sys.flags.interactive == 0", sys.flags.interactive == 0,
"got %r" % (sys.flags.interactive,))
# Leave the interpreter as we found it.
del sys.ps1
if failures:
print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures)))
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,44 +1,61 @@
"""Regression test for Grid backward compatibility with GridView shim (#252)."""
"""Regression test for Grid/GridView/GridData compatibility (#252, updated for #313/#361).
The old GridView "shim" model (Grid owns data, .view property auto-creates a separate
GridView object) is gone. Current contract: mcrfpy.Grid IS mcrfpy.GridView (same type);
the view owns rendering state (zoom/center/camera), and the shared map lives in
.grid_data (a mcrfpy.GridData -- cells, entities, layers; not a drawable).
The intent of each test below is preserved; the assertions were retargeted at the
current contract.
"""
import mcrfpy
import sys
def test_grid_creates_view():
"""Grid auto-creates a GridView accessible via .view property."""
def test_grid_creates_grid_data():
"""Grid auto-creates its GridData, accessible via .grid_data."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
assert grid.view is not None, "Grid should auto-create a view"
assert isinstance(grid.view, mcrfpy.GridView)
print("PASS: Grid auto-creates view")
assert grid.grid_data is not None, "Grid should auto-create its grid data"
assert isinstance(grid.grid_data, mcrfpy.GridData)
# Grid and GridView are the same type now (alias), not shim + shimmed object.
assert mcrfpy.Grid is mcrfpy.GridView, "Grid should be an alias of GridView"
assert isinstance(grid, mcrfpy.GridView)
print("PASS: Grid auto-creates grid data")
def test_view_shares_grid_data():
"""GridView created by shim shares the same grid data."""
"""A GridView constructed over an existing Grid shares the same GridData."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
view = grid.view
view = mcrfpy.GridView(grid=grid, pos=(0, 0), size=(160, 160))
# View's grid should be the same Grid
assert view.grid is grid, "view.grid should be the same Grid"
assert view.grid.grid_w == 10
# The view must share -- not copy -- the source grid's data.
assert view.grid_data is grid.grid_data, "view should share the Grid's GridData"
assert view.grid_data.grid_w == 10
# Passing the GridData itself is equivalent.
view2 = mcrfpy.GridView(grid=grid.grid_data, pos=(0, 0), size=(160, 160))
assert view2.grid_data is grid.grid_data, "GridView(grid=GridData) should share it"
print("PASS: view shares grid data")
def test_rendering_property_sync():
"""Setting rendering properties on Grid syncs to the view."""
def test_rendering_properties():
"""Rendering properties live on the view (Grid) itself and round-trip."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
view = grid.view
grid.zoom = 3.0
assert abs(view.zoom - 3.0) < 0.01, f"View zoom should sync: {view.zoom}"
assert abs(grid.zoom - 3.0) < 0.01, f"zoom should round-trip: {grid.zoom}"
grid.center_x = 200.0
assert abs(view.center.x - 200.0) < 0.01, f"View center_x should sync: {view.center.x}"
assert abs(grid.center.x - 200.0) < 0.01, f"center.x should track center_x: {grid.center.x}"
grid.center_y = 150.0
assert abs(view.center.y - 150.0) < 0.01, f"View center_y should sync: {view.center.y}"
assert abs(grid.center.y - 150.0) < 0.01, f"center.y should track center_y: {grid.center.y}"
grid.camera_rotation = 45.0
# camera_rotation syncs through set_float_member
print("PASS: rendering properties sync to view")
assert abs(grid.camera_rotation - 45.0) < 0.01, f"camera_rotation should round-trip: {grid.camera_rotation}"
# Rendering state is view-only; it must not have leaked onto the shared map.
assert not hasattr(grid.grid_data, "zoom"), "GridData must not carry rendering state"
print("PASS: rendering properties live on the view")
def test_grid_still_works_in_scene():
"""Grid can still be appended to scenes and works as before."""
@ -52,7 +69,8 @@ def test_grid_still_works_in_scene():
# Grid is in the scene as itself (not substituted)
assert len(scene.children) == 1
retrieved = scene.children[0]
assert type(retrieved).__name__ == "Grid", f"Expected Grid, got {type(retrieved).__name__}"
assert retrieved is grid, "scene.children should return the same object (#369)"
assert type(retrieved).__name__ == "GridView", f"Expected GridView, got {type(retrieved).__name__}"
print("PASS: Grid works in scene as before")
def test_grid_subclass_preserved():
@ -80,47 +98,66 @@ def test_entity_operations_unaffected():
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
data = grid.grid_data
for y in range(20):
for x in range(20):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
data.at(x, y).walkable = True
data.at(x, y).transparent = True
e = mcrfpy.Entity((5, 5), grid=grid)
assert len(grid.entities) == 1
assert len(data.entities) == 1
assert e.cell_x == 5
assert e.cell_y == 5
assert len(grid.at(5, 5).entities) == 1
assert len(data.at(5, 5).entities) == 1
# Current contract (#313/#361): entity.grid is the shared GridData, not the view.
assert e.grid is data, "entity.grid should be the GridData it lives in"
print("PASS: entity operations unaffected")
def test_gridview_independent_rendering():
"""A GridView can have different rendering settings from the Grid."""
"""A second GridView over the same data has independent rendering settings."""
scene = mcrfpy.Scene("test_independent")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
# Create an explicit GridView with different settings
# Create an explicit second view over the same grid data, with different settings
view2 = mcrfpy.GridView(grid=grid, pos=(350, 0), size=(160, 160), zoom=0.5)
scene.children.append(grid)
scene.children.append(view2)
assert view2.grid_data is grid.grid_data, "both views should share one GridData"
# Grid and view2 should have different zoom
assert abs(grid.zoom - 1.0) < 0.01
assert abs(view2.zoom - 0.5) < 0.01
# Changing grid zoom doesn't affect explicit GridView
# Changing grid zoom doesn't affect the second view
grid.zoom = 2.0
assert abs(view2.zoom - 0.5) < 0.01, "Explicit GridView should keep its own zoom"
assert abs(view2.zoom - 0.5) < 0.01, "Second GridView should keep its own zoom"
assert abs(grid.zoom - 2.0) < 0.01
print("PASS: GridView independent rendering")
if __name__ == "__main__":
test_grid_creates_view()
test_view_shares_grid_data()
test_rendering_property_sync()
test_grid_still_works_in_scene()
test_grid_subclass_preserved()
test_entity_operations_unaffected()
test_gridview_independent_rendering()
tests = [
test_grid_creates_grid_data,
test_view_shares_grid_data,
test_rendering_properties,
test_grid_still_works_in_scene,
test_grid_subclass_preserved,
test_entity_operations_unaffected,
test_gridview_independent_rendering,
]
failures = 0
for t in tests:
try:
t()
except AssertionError as e:
failures += 1
print(f"FAIL: {t.__name__}: {e}")
if failures:
print(f"FAILED: {failures} of {len(tests)} tests failed")
sys.exit(1)
print("All backward compatibility tests passed")
print("PASS")
sys.exit(0)

View file

@ -12,9 +12,18 @@ while small grids use the original flat storage. Verifies that:
NOTE: This test uses ColorLayer for color operations since cell.color
is no longer supported. The chunk system affects internal storage, which
ColorLayer also uses.
API notes (current contract):
- add_layer() takes a layer OBJECT and no keyword arguments:
grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
- ColorLayer.set()/at() take a POSITION tuple, not separate x/y args to set().
- GridPoint no longer has .tilesprite; cell read/write coverage is preserved
by exercising .walkable (a real per-cell field that lives in the same
chunked storage the issue is about).
"""
import mcrfpy
from mcrfpy import automation
import sys
def test_small_grid():
@ -23,23 +32,28 @@ def test_small_grid():
# Small grid should use flat storage
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Set some cells
for y in range(50):
for x in range(50):
cell = grid.at(x, y)
color_layer.set(x, y, mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
cell.tilesprite = -1
color_layer.set((x, y), mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
cell.walkable = ((x + y) % 2 == 0)
# Verify cells
expected_r = (25 * 5) % 256
expected_g = (25 * 5) % 256
color = color_layer.at(25, 25)
color = color_layer.at((25, 25))
if color.r != expected_r or color.g != expected_g:
print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({color.r}, {color.g})")
return False
# Verify per-cell (non-color) storage round-trips
if not grid.at(24, 24).walkable or grid.at(25, 24).walkable:
print("FAIL: Small grid cell walkable mismatch")
return False
print(" Small grid: PASS")
return True
@ -49,7 +63,7 @@ def test_large_grid():
# Large grid should use chunk storage (100 > 64)
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Set cells across multiple chunks
# Chunks are 64x64, so a 100x100 grid has 2x2 = 4 chunks
@ -65,15 +79,18 @@ def test_large_grid():
for x, y in test_points:
cell = grid.at(x, y)
color_layer.set(x, y, mcrfpy.Color(x, y, 100, 255))
cell.tilesprite = -1
color_layer.set((x, y), mcrfpy.Color(x, y, 100, 255))
cell.walkable = False
# Verify cells
for x, y in test_points:
color = color_layer.at(x, y)
color = color_layer.at((x, y))
if color.r != x or color.g != y:
print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color.r}, {color.g})")
return False
if grid.at(x, y).walkable:
print(f"FAIL: Large grid cell ({x},{y}) walkable not persisted across chunk boundary")
return False
print(" Large grid cell access: PASS")
return True
@ -84,7 +101,7 @@ def test_very_large_grid():
# 500x500 = 250,000 cells, should use ~64 chunks (8x8)
grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Set some cells at various positions
test_points = [
@ -98,11 +115,11 @@ def test_very_large_grid():
]
for x, y in test_points:
color_layer.set(x, y, mcrfpy.Color(x % 256, y % 256, 200, 255))
color_layer.set((x, y), mcrfpy.Color(x % 256, y % 256, 200, 255))
# Verify
for x, y in test_points:
color = color_layer.at(x, y)
color = color_layer.at((x, y))
if color.r != (x % 256) or color.g != (y % 256):
print(f"FAIL: Very large grid cell ({x},{y}) color mismatch")
return False
@ -116,18 +133,18 @@ def test_boundary_case():
# 64x64 should use flat storage (not exceeding threshold)
grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400))
color_layer_64 = grid_64.add_layer("color", z_index=-1)
color_layer_64.set(63, 63, mcrfpy.Color(255, 0, 0, 255))
color = color_layer_64.at(63, 63)
color_layer_64 = grid_64.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
color_layer_64.set((63, 63), mcrfpy.Color(255, 0, 0, 255))
color = color_layer_64.at((63, 63))
if color.r != 255:
print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}")
return False
# 65x65 should use chunk storage (exceeding threshold)
grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400))
color_layer_65 = grid_65.add_layer("color", z_index=-1)
color_layer_65.set(64, 64, mcrfpy.Color(0, 255, 0, 255))
color = color_layer_65.at(64, 64)
color_layer_65 = grid_65.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
color_layer_65.set((64, 64), mcrfpy.Color(0, 255, 0, 255))
color = color_layer_65.at((64, 64))
if color.g != 255:
print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}")
return False
@ -141,16 +158,16 @@ def test_edge_cases():
# Create 100x100 grid
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Test all corners
corners = [(0, 0), (99, 0), (0, 99), (99, 99)]
for i, (x, y) in enumerate(corners):
color_layer.set(x, y, mcrfpy.Color(i * 60, i * 60, i * 60, 255))
color_layer.set((x, y), mcrfpy.Color(i * 60, i * 60, i * 60, 255))
for i, (x, y) in enumerate(corners):
expected = i * 60
color = color_layer.at(x, y)
color = color_layer.at((x, y))
if color.r != expected:
print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}")
return False
@ -158,6 +175,31 @@ def test_edge_cases():
print(" Edge cases: PASS")
return True
def test_rendering(scene):
"""Test that both storage modes actually render (flat + chunked on screen)"""
print("Testing rendering of flat and chunked grids...")
small = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(200, 200))
small_colors = small.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
large = mcrfpy.Grid(grid_size=(100, 100), pos=(220, 10), size=(200, 200))
large_colors = large.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
for i in range(50):
small_colors.set((i, i), mcrfpy.Color(255, 0, 0, 255))
for i in range(100):
large_colors.set((i, i), mcrfpy.Color(0, 0, 255, 255))
scene.children.append(small)
scene.children.append(large)
# Rendering is orthogonal to sim time; screenshot forces a render pass.
if not automation.screenshot("issue_123_chunk_render.png"):
print("FAIL: screenshot of flat + chunked grids failed")
return False
print(" Rendering: PASS")
return True
# Main
if __name__ == "__main__":
print("=" * 60)
@ -173,9 +215,11 @@ if __name__ == "__main__":
results.append(test_very_large_grid())
results.append(test_boundary_case())
results.append(test_edge_cases())
results.append(test_rendering(test))
if all(results):
print("\n=== ALL TESTS PASSED ===")
print("PASS")
sys.exit(0)
else:
print("\n=== SOME TESTS FAILED ===")

View file

@ -22,7 +22,7 @@ print("=" * 60)
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
ui = test.children
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(pos=(0,0), size=(400,300), grid_size=(50, 50), texture=texture)
ui.append(grid)
@ -34,13 +34,15 @@ for y in range(50):
cell.walkable = True
cell.transparent = True
# Add some walls to test blocking
for i in range(10, 20):
grid.at(i, 25).transparent = False
grid.at(i, 25).walkable = False
# Add a wall that actually occludes: a vertical run at x=20, spanning the
# rows around the viewer at (25,25). Cells at x < 20 on row 25 are then
# within radius but geometrically behind the wall.
for j in range(20, 31):
grid.at(20, j).transparent = False
grid.at(20, j).walkable = False
print("\n--- Test 1: compute_fov() returns None ---")
result = grid.compute_fov(25, 25, radius=10)
result = grid.compute_fov((25, 25), radius=10)
if result is None:
print(" PASS: compute_fov() returned None")
else:
@ -55,16 +57,16 @@ else:
print(" FAIL: Center should be in FOV")
sys.exit(1)
# Cell within radius should be visible
if grid.is_in_fov(20, 25):
print(" PASS: Cell (20,25) within radius is in FOV")
# Cell within radius and in front of the wall should be visible
if grid.is_in_fov(22, 25):
print(" PASS: Cell (22,25) within radius is in FOV")
else:
print(" FAIL: Cell (20,25) should be in FOV")
print(" FAIL: Cell (22,25) should be in FOV")
sys.exit(1)
# Cell behind wall should NOT be visible
if not grid.is_in_fov(15, 30):
print(" PASS: Cell (15,30) behind wall is NOT in FOV")
# Cell behind the wall (within radius) should NOT be visible
if not grid.is_in_fov(18, 25):
print(" PASS: Cell (18,25) behind wall is NOT in FOV")
else:
print(" FAIL: Cell behind wall should not be in FOV")
sys.exit(1)
@ -89,7 +91,7 @@ for y in range(0, 200, 5): # Sample for speed
times = []
for i in range(5):
t0 = time.perf_counter()
grid_large.compute_fov(100, 100, radius=15)
grid_large.compute_fov((100, 100), radius=15)
elapsed = (time.perf_counter() - t0) * 1000
times.append(elapsed)

View file

@ -7,6 +7,13 @@ Tests:
2. TileLayer creation and manipulation
3. Layer z_index ordering relative to entities
4. Layer management (add_layer, remove_layer, layers property)
API notes (current contract):
- Layers are constructed standalone (TileLayer/ColorLayer ctors take kwargs) and then
attached with grid_data.add_layer(layer) -- add_layer takes an object, not kwargs.
- Layer management (add_layer / remove_layer / layer) lives on GridData
(grid.grid_data); the Grid view exposes the read-only `layers` tuple.
- Layers are looked up BY NAME, not by z_index (grid_data.layer(name)).
"""
import mcrfpy
import sys
@ -19,21 +26,22 @@ print("=" * 60)
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
ui = test.children
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
# Create grid with explicit empty layers (#150 migration)
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers={})
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers=[])
ui.append(grid)
grid_data = grid.grid_data
print("\n--- Test 1: Initial state (no layers) ---")
if len(grid.layers) == 0:
print(" PASS: Grid starts with no layers (layers={})")
print(" PASS: Grid starts with no layers (layers=[])")
else:
print(f" FAIL: Expected 0 layers, got {len(grid.layers)}")
sys.exit(1)
print("\n--- Test 2: Add ColorLayer ---")
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
print(f" Created: {color_layer}")
if color_layer is not None:
print(" PASS: ColorLayer created")
@ -63,7 +71,7 @@ else:
print("\n--- Test 3: ColorLayer cell access ---")
# Set a color
color_layer.set(5, 5, mcrfpy.Color(255, 0, 0, 128))
color_layer.set((5, 5), mcrfpy.Color(255, 0, 0, 128))
color = color_layer.at(5, 5)
if color.r == 255 and color.g == 0 and color.b == 0 and color.a == 128:
print(f" PASS: Color at (5,5) is {color.r}, {color.g}, {color.b}, {color.a}")
@ -81,7 +89,7 @@ else:
sys.exit(1)
print("\n--- Test 4: Add TileLayer ---")
tile_layer = grid.add_layer("tile", z_index=-2, texture=texture)
tile_layer = grid_data.add_layer(mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture))
print(f" Created: {tile_layer}")
if tile_layer is not None:
print(" PASS: TileLayer created")
@ -97,7 +105,7 @@ else:
print("\n--- Test 5: TileLayer cell access ---")
# Set a tile
tile_layer.set(3, 3, 42)
tile_layer.set((3, 3), 42)
tile = tile_layer.at(3, 3)
if tile == 42:
print(f" PASS: Tile at (3,3) is {tile}")
@ -129,30 +137,34 @@ else:
print(f" FAIL: Layers not sorted")
sys.exit(1)
print("\n--- Test 7: Get layer by z_index ---")
layer = grid.layer(-1)
if layer is not None and layer.z_index == -1:
print(" PASS: grid.layer(-1) returns ColorLayer")
print("\n--- Test 7: Get layer by name ---")
# Layer lookup is by NAME now (grid.layer(z_index) no longer exists); z_index is still
# the ordering key, verified in Test 6. Note: layer lookups return a fresh wrapper each
# call (layers are not in PythonObjectCache), so compare identity via the shared data,
# not `is`.
layer = grid_data.layer("color")
if layer is not None and layer.z_index == -1 and layer.at(5, 5).b == color_layer.at(5, 5).b:
print(" PASS: grid_data.layer('color') returns the ColorLayer")
else:
print(" FAIL: Could not get layer by z_index")
print(" FAIL: Could not get ColorLayer by name")
sys.exit(1)
layer = grid.layer(-2)
if layer is not None and layer.z_index == -2:
print(" PASS: grid.layer(-2) returns TileLayer")
layer = grid_data.layer("tile")
if layer is not None and layer.z_index == -2 and layer.at(3, 3) == tile_layer.at(3, 3):
print(" PASS: grid_data.layer('tile') returns the TileLayer")
else:
print(" FAIL: Could not get layer by z_index")
print(" FAIL: Could not get TileLayer by name")
sys.exit(1)
layer = grid.layer(999)
layer = grid_data.layer("nonexistent")
if layer is None:
print(" PASS: grid.layer(999) returns None for non-existent layer")
print(" PASS: grid_data.layer('nonexistent') returns None for non-existent layer")
else:
print(" FAIL: Should return None for non-existent layer")
sys.exit(1)
print("\n--- Test 8: Layer above entities (z_index >= 0) ---")
fog_layer = grid.add_layer("color", z_index=1)
fog_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="fog", z_index=1))
if fog_layer.z_index == 1:
print(" PASS: Created layer with z_index=1 (above entities)")
else:
@ -165,7 +177,7 @@ print(" PASS: Fog layer filled")
print("\n--- Test 9: Remove layer ---")
initial_count = len(grid.layers)
grid.remove_layer(fog_layer)
grid_data.remove_layer(fog_layer)
final_count = len(grid.layers)
if final_count == initial_count - 1:
print(f" PASS: Layer removed ({initial_count} -> {final_count})")

View file

@ -7,12 +7,64 @@ Tests:
2. Setting cell values marks layer dirty
3. Fill operation marks layer dirty
4. Texture change marks TileLayer dirty
5. Viewport changes (center/zoom) don't trigger re-render (static benchmark)
6. Performance improvement for static layers
5. Viewport changes (center/zoom) don't corrupt the cached texture
6. Performance / large-layer handling for static layers
7. Layer visibility toggle marks the layer dirty
8. Large grid stress test
REPAIR NOTE (API drift):
* add_layer() no longer takes kwargs and no longer constructs the layer for you.
Construct a TileLayer/ColorLayer and attach it: grid.add_layer(layer).
* layer.set() takes a position TUPLE: layer.set((x, y), value) -- not set(x, y, value).
* assets/kenney_ice.png does not exist; using kenney_tinydungeon.png.
* step() is the clock and NEVER renders (#350); rendering is forced with
automation.screenshot(), which costs zero simulation time.
REPAIR NOTE (real coverage):
The original file punted on the actual subject of #148 -- it said "dirty flag
behavior is internal" and only checked that the API didn't crash, so it proved
nothing about caching. Dirty-flag behavior IS observable from Python now: the
GridView caches its composed output and only re-renders when a layer's markDirty()
bumps the grid's content_generation (#351). So a mutation that fails to set the
dirty flag produces a byte-identical screenshot -- a stale cache. We render to PNG
and compare hashes to assert invalidation actually happens.
"""
import mcrfpy
from mcrfpy import automation
import sys
import time
import os
import hashlib
import tempfile
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
_shot_n = [0]
def render_hash():
"""Force a render (screenshot) and hash the pixels.
step() never renders, so this is the only way to observe what the grid's cached
RenderTexture actually contains. Identical bytes across a mutation == stale cache
== the dirty flag was not set.
"""
_shot_n[0] += 1
path = os.path.join(tempfile.gettempdir(), f"issue148_{_shot_n[0]}.png")
automation.screenshot(path)
with open(path, "rb") as f:
h = hashlib.sha256(f.read()).hexdigest()
os.remove(path)
return h
print("=" * 60)
print("Issue #148 Regression Test: Layer Dirty Flags and Caching")
@ -22,126 +74,186 @@ print("=" * 60)
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
ui = test.children
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
# Create grid with larger size for performance testing
grid = mcrfpy.Grid(pos=(50, 50), size=(500, 400), grid_size=(50, 40), texture=texture)
ui.append(grid)
print("\n--- Test 1: Layer creation (starts dirty) ---")
color_layer = grid.add_layer("color", z_index=-1)
# The layer should be dirty initially
# We can't directly check dirty flag from Python, but we verify the system works
# A grid built with grid_size= already carries a default tile layer; these are added
# on top of it. Layers attached at size (0,0) auto-resize to the grid.
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
print(" ColorLayer created successfully")
tile_layer = grid.add_layer("tile", z_index=-2, texture=texture)
tile_layer = mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture)
grid.add_layer(tile_layer)
print(" TileLayer created successfully")
print(" PASS: Layers created")
print("\n--- Test 2: Fill operations work ---")
# Fill with some data
check("ColorLayer attached and retrievable by name", grid.layer("color") is not None)
check("TileLayer attached and retrievable by name", grid.layer("tile") is not None)
check("layer auto-resized to grid", color_layer.grid_size == (50, 40),
f"got {color_layer.grid_size}")
print("\n--- Test 2: Fill operations work (and mark the layer dirty) ---")
color_layer.fill(mcrfpy.Color(128, 0, 128, 64))
print(" ColorLayer filled with purple overlay")
check("ColorLayer.fill wrote every cell", color_layer.at((0, 0)) == mcrfpy.Color(128, 0, 128, 64)
and color_layer.at((49, 39)) == mcrfpy.Color(128, 0, 128, 64),
f"got {color_layer.at((0, 0))} / {color_layer.at((49, 39))}")
tile_layer.fill(5) # Fill with tile index 5
print(" TileLayer filled with tile index 5")
print(" PASS: Fill operations completed")
tile_layer.fill(5)
check("TileLayer.fill wrote every cell",
tile_layer.at((0, 0)) == 5 and tile_layer.at((49, 39)) == 5,
f"got {tile_layer.at((0, 0))} / {tile_layer.at((49, 39))}")
print("\n--- Test 3: Cell set operations work ---")
# Set individual cells
color_layer.set(10, 10, mcrfpy.Color(255, 255, 0, 128))
color_layer.set(11, 10, mcrfpy.Color(255, 255, 0, 128))
color_layer.set(10, 11, mcrfpy.Color(255, 255, 0, 128))
color_layer.set(11, 11, mcrfpy.Color(255, 255, 0, 128))
print(" Set 4 cells in ColorLayer to yellow")
yellow = mcrfpy.Color(255, 255, 0, 128)
for cell in [(10, 10), (11, 10), (10, 11), (11, 11)]:
color_layer.set(cell, yellow)
check("ColorLayer.set updated the 4 target cells",
all(color_layer.at(c) == yellow for c in [(10, 10), (11, 10), (10, 11), (11, 11)]))
check("ColorLayer.set left neighbours untouched",
color_layer.at((12, 10)) == mcrfpy.Color(128, 0, 128, 64),
f"got {color_layer.at((12, 10))}")
tile_layer.set(15, 15, 10)
tile_layer.set(16, 15, 11)
tile_layer.set(15, 16, 10)
tile_layer.set(16, 16, 11)
print(" Set 4 cells in TileLayer to different tiles")
print(" PASS: Cell set operations completed")
for cell, idx in [((15, 15), 10), ((16, 15), 11), ((15, 16), 10), ((16, 16), 11)]:
tile_layer.set(cell, idx)
check("TileLayer.set updated the 4 target cells",
tile_layer.at((15, 15)) == 10 and tile_layer.at((16, 15)) == 11
and tile_layer.at((15, 16)) == 10 and tile_layer.at((16, 16)) == 11)
check("TileLayer.set(-1) clears a tile",
(tile_layer.set((20, 20), -1), tile_layer.at((20, 20)))[1] == -1,
f"got {tile_layer.at((20, 20))}")
print("\n--- Test 4: Texture change on TileLayer ---")
# Create a second texture and assign it
texture2 = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
# Note: the .texture getter returns a fresh wrapper each call and Texture has no
# __eq__, so identity/equality can't be asserted -- compare the source path instead.
texture2 = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
tile_layer.texture = texture2
print(" Changed TileLayer texture")
check("TileLayer.texture accepts a new texture",
tile_layer.texture.source == "assets/kenney_TD_MR_IP.png",
f"got {tile_layer.texture.source}")
# Set back to original
tile_layer.texture = texture
print(" Restored original texture")
print(" PASS: Texture changes work")
check("TileLayer.texture restores the original",
tile_layer.texture.source == "assets/kenney_tinydungeon.png",
f"got {tile_layer.texture.source}")
print("\n--- Test 5: Viewport changes (should use cached texture) ---")
# Pan around - these should NOT cause layer re-renders (just blit different region)
print("\n--- Test 5: Dirty flags actually invalidate the cached RenderTexture ---")
# This is the core of #148. The grid caches its composed output; a mutation that does
# not set the dirty flag yields a byte-identical render (a stale cache).
# The ColorLayer (z=-1) composites OVER the TileLayer (z=-2), so it is kept
# translucent -- an opaque fill would occlude the tile layer and make the TileLayer
# invalidation checks below vacuous.
base = render_hash()
repeat = render_hash()
check("re-rendering an unchanged grid is stable", base == repeat)
color_layer.fill(mcrfpy.Color(255, 0, 0, 64))
after_fill = render_hash()
check("fill() invalidates the cache (render changed)", after_fill != base)
color_layer.set((0, 0), mcrfpy.Color(0, 255, 0, 64))
after_set = render_hash()
check("set() invalidates the cache (render changed)", after_set != after_fill)
tile_layer.fill(7)
after_tile_fill = render_hash()
check("TileLayer.fill() invalidates the cache", after_tile_fill != after_set)
tile_layer.set((5, 5), 12)
after_tile_set = render_hash()
check("TileLayer.set() invalidates the cache", after_tile_set != after_tile_fill)
# Bulk edit path (#328): the view is a 2-D int32 memoryview aliasing layer storage;
# on __exit__ the whole layer is conservatively invalidated.
with tile_layer.edit() as view:
for y in range(40):
for x in range(50):
view[y, x] = 3
check("bulk edit() wrote through to layer storage", tile_layer.at((5, 5)) == 3,
f"got {tile_layer.at((5, 5))}")
after_edit = render_hash()
check("bulk edit() invalidates the cache", after_edit != after_tile_set)
print("\n--- Test 6: Viewport changes (should use the cached texture) ---")
original_center = grid.center
print(f" Original center: {original_center}")
original_zoom = grid.zoom
print(f" Original center: {original_center}, zoom: {original_zoom}")
# Perform multiple viewport changes
for i in range(10):
grid.center = (100 + i * 20, 80 + i * 10)
print(" Performed 10 center changes")
check("center round-trips after 10 changes", grid.center == (280, 170),
f"got {grid.center}")
panned = render_hash()
check("panning the viewport changes the rendered image", panned != after_edit)
# Zoom changes
original_zoom = grid.zoom
for z in [1.0, 0.8, 1.2, 0.5, 1.5, 1.0]:
grid.zoom = z
print(" Performed 6 zoom changes")
check("zoom round-trips after 6 changes", grid.zoom == 1.0, f"got {grid.zoom}")
# Restore
# Restoring the viewport must restore the exact same image: the layer content never
# changed, so this exercises the cache-hit path (blit a different region, no re-render).
grid.center = original_center
grid.zoom = original_zoom
print(" PASS: Viewport changes completed without crashing")
print("\n--- Test 6: Performance benchmark ---")
# Create a large layer for performance testing
perf_grid = mcrfpy.Grid(pos=(50, 50), size=(600, 500), grid_size=(100, 80), texture=texture)
ui.append(perf_grid)
perf_layer = perf_grid.add_layer("tile", z_index=-1, texture=texture)
# Fill with data
perf_layer.fill(1)
# Render a frame to build cache
mcrfpy.step(0.01)
# Subsequent viewport changes should be fast (cache hit)
start = time.time()
for i in range(5):
perf_grid.center = (200 + i * 10, 160 + i * 8)
viewport_changes = time.time() - start
print(f" 5 viewport changes: {viewport_changes*1000:.2f}ms")
print(" PASS: Performance benchmark completed")
restored = render_hash()
check("restoring the viewport restores the identical image", restored == after_edit)
print("\n--- Test 7: Layer visibility toggle ---")
# The layer's own render() early-outs on `visible`, so hiding a layer must change the
# rendered output. It only does so if the setter invalidates the grid's cache.
visible_hash = render_hash()
color_layer.visible = False
print(" ColorLayer hidden")
check("visible attribute round-trips to False", color_layer.visible is False)
hidden_hash = render_hash()
check("hiding a layer changes the rendered image (dirty flag set)",
hidden_hash != visible_hash,
"-- layer.visible setter does not invalidate the grid cache (see notes)")
color_layer.visible = True
print(" ColorLayer shown")
print(" PASS: Visibility toggle works")
check("visible attribute round-trips to True", color_layer.visible is True)
reshown_hash = render_hash()
check("re-showing a layer restores the rendered image",
reshown_hash == visible_hash,
"-- layer.visible setter does not invalidate the grid cache (see notes)")
print("\n--- Test 8: Large grid stress test ---")
# Test with maximum size grid to ensure texture caching works
stress_scene = mcrfpy.Scene("stress")
stress_grid = mcrfpy.Grid(pos=(10, 10), size=(200, 150), grid_size=(200, 150), texture=texture)
ui.append(stress_grid)
stress_layer = stress_grid.add_layer("color", z_index=-1)
stress_scene.children.append(stress_grid)
stress_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
stress_grid.add_layer(stress_layer)
# This would be 30,000 cells - should handle via caching
# 30,000 cells - should be handled via chunked texture caching
stress_layer.fill(mcrfpy.Color(0, 100, 200, 100))
# Set a few specific cells
for x in range(10):
for y in range(10):
stress_layer.set(x, y, mcrfpy.Color(255, 0, 0, 200))
stress_layer.set((x, y), mcrfpy.Color(255, 0, 0, 200))
print(" Created 200x150 grid with 30,000 cells")
print(" PASS: Large grid handled successfully")
check("30,000-cell layer filled", stress_layer.at((199, 149)) == mcrfpy.Color(0, 100, 200, 100),
f"got {stress_layer.at((199, 149))}")
check("30,000-cell layer accepts per-cell sets",
stress_layer.at((9, 9)) == mcrfpy.Color(255, 0, 0, 200),
f"got {stress_layer.at((9, 9))}")
mcrfpy.current_scene = stress_scene
mcrfpy.step(0.016)
stress_render = render_hash()
check("large grid renders without crashing", len(stress_render) == 64)
# A static layer must be re-renderable from cache: identical bytes, no corruption.
check("static large layer renders identically from cache",
render_hash() == stress_render)
print("\n" + "=" * 60)
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
print("=" * 60)
sys.exit(1)
print("All tests PASSED")
print("=" * 60)
print("\nNote: Dirty flag behavior is internal - tests verify API works")
print("Actual caching benefits are measured by render performance.")
sys.exit(0)

View file

@ -1,4 +1,4 @@
"""Regression test: entity gridstate must resize when moving between grids.
"""Regression test: entity perspective map must resize when moving between grids.
Issues #258-#263, #274, #276, #278: UIEntity gridstate heap overflows.
@ -7,139 +7,159 @@ moved from a small grid to a larger grid via ANY transfer method, gridstate
kept the old size. Code then iterated using the new grid's dimensions,
writing past the vector's end.
Fix: ensureGridstate() unconditionally checks gridstate.size() against
grid dimensions and resizes if they don't match. Applied to all transfer
methods: set_grid, append, extend, insert, setitem, slice assignment.
Fix: the per-entity visibility buffer unconditionally checks its size against
the grid dimensions and reallocates if they don't match. Exercised here through
every transfer method: set_grid, append, extend, insert, setitem, slice assign.
Also tests #274: spatial_hash.remove() must be called when removing
entities from grids via set_grid(None) or set_grid(other_grid).
API NOTE (#294): entity.gridstate is now entity.perspective_map, a bounds-checked
DiscreteMap (UNKNOWN/DISCOVERED/VISIBLE) carrying its own .size, and it is *lazy* --
a transfer marks it stale and the next update_visibility() sizes it to the new grid
(see UIEntity::updateVisibility, src/UIEntity.cpp:51). So each case below transfers,
then calls update_visibility(), then asserts the buffer matches the new grid and that
cells only reachable in the NEW (larger) grid are addressable. Under the old bug those
reads/writes ran off the end of the old buffer.
"""
import mcrfpy
import sys
def open_grid(size):
"""Grid of `size` x `size` with all cells transparent/walkable, FOV enabled."""
grid = mcrfpy.Grid(grid_size=(size, size))
data = grid.grid_data
for x in range(size):
for y in range(size):
point = data.at(x, y)
point.transparent = True
point.walkable = True
data.fov_radius = 4
return grid
def check_sized_to(entity, grid, label):
"""Buffer must match the grid, and the grid's far corner must be addressable."""
entity.update_visibility()
pmap = entity.perspective_map
w, h = grid.grid_data.grid_w, grid.grid_data.grid_h
assert pmap.size == (w, h), f"{label}: expected {(w, h)}, got {pmap.size}"
# Under #258 this read walked off the end of the stale, smaller buffer.
pmap.get(w - 1, h - 1)
def test_set_grid():
"""entity.grid = new_grid resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(50, 50))
"""entity.grid = new_grid resizes the perspective map"""
small = open_grid(10)
large = open_grid(50)
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
small.perspective = entity
small.fov_radius = 4
entity.update_visibility()
gs = entity.gridstate
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
check_sized_to(entity, small, "set_grid/small")
entity.grid = large
gs = entity.gridstate
assert len(gs) == 2500, f"Expected 2500, got {len(gs)}"
large.perspective = entity
large.fov_radius = 8
entity.update_visibility()
check_sized_to(entity, large, "set_grid/large")
print(" PASS: set_grid")
def test_append():
"""grid.entities.append(entity) resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(40, 40))
"""grid.entities.append(entity) resizes the perspective map"""
small = open_grid(10)
large = open_grid(40)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
gs = entity.gridstate
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
check_sized_to(entity, small, "append/small")
large.entities.append(entity)
gs = entity.gridstate
assert len(gs) == 1600, f"Expected 1600, got {len(gs)}"
check_sized_to(entity, large, "append/large")
print(" PASS: append")
def test_extend():
"""grid.entities.extend([entity]) resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(30, 30))
"""grid.entities.extend([entity]) resizes the perspective map"""
small = open_grid(10)
large = open_grid(30)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "extend/small")
large.entities.extend([entity])
gs = entity.gridstate
assert len(gs) == 900, f"Expected 900, got {len(gs)}"
check_sized_to(entity, large, "extend/large")
print(" PASS: extend")
def test_insert():
"""grid.entities.insert(0, entity) resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(25, 25))
"""grid.entities.insert(0, entity) resizes the perspective map"""
small = open_grid(10)
large = open_grid(25)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "insert/small")
large.entities.insert(0, entity)
gs = entity.gridstate
assert len(gs) == 625, f"Expected 625, got {len(gs)}"
check_sized_to(entity, large, "insert/large")
print(" PASS: insert")
def test_setitem():
"""grid.entities[0] = entity resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(20, 20))
"""grid.entities[0] = entity resizes the perspective map"""
small = open_grid(10)
large = open_grid(20)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "setitem/small")
# Need a placeholder entity in large grid first
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
large.entities[0] = entity
gs = entity.gridstate
assert len(gs) == 400, f"Expected 400, got {len(gs)}"
assert len(large.entities) == 1
check_sized_to(entity, large, "setitem/large")
print(" PASS: setitem")
def test_slice_assign():
"""grid.entities[0:1] = [entity] resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(35, 35))
"""grid.entities[0:1] = [entity] resizes the perspective map"""
small = open_grid(10)
large = open_grid(35)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "slice/small")
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
large.entities[0:1] = [entity]
gs = entity.gridstate
assert len(gs) == 1225, f"Expected 1225, got {len(gs)}"
check_sized_to(entity, large, "slice/large")
print(" PASS: slice_assign")
def test_update_visibility_after_transfer():
"""update_visibility works correctly after all transfer methods"""
grids = [mcrfpy.Grid(grid_size=(s, s)) for s in (5, 80, 3, 60, 10, 100)]
"""update_visibility works correctly after repeated grow/shrink transfers"""
grids = [open_grid(s) for s in (5, 80, 3, 60, 10, 100)]
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grids[0])
for g in grids:
entity.grid = g
g.perspective = entity
g.fov_radius = 4
entity.update_visibility()
gs = entity.gridstate
expected = g.grid_w * g.grid_h
assert len(gs) == expected, f"Expected {expected}, got {len(gs)}"
check_sized_to(entity, g, f"cycle/{g.grid_data.grid_w}")
print(" PASS: update_visibility_after_transfer")
def test_at_after_transfer():
"""entity.at(x, y) works correctly after grid transfer"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(50, 50))
small = open_grid(10)
large = open_grid(50)
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
entity.update_visibility()
entity.grid = large
# Access a cell that would be out of bounds for the small grid
large.perspective = entity
# Stand on a cell that is out of bounds for the small grid entirely.
entity.grid_pos = (30, 30)
entity.update_visibility()
# at() returns the GridPoint only when the cell is VISIBLE to this entity.
state = entity.at(30, 30)
assert state is not None
assert state is not None, "entity's own cell must be visible after transfer"
assert tuple(state.grid_pos) == (30, 30), f"got {tuple(state.grid_pos)}"
# Far side of the new grid: addressable (not a heap read), just not visible.
assert entity.at(5, 5) is None, "cell outside FOV must report as not visible"
assert entity.perspective_map.get(49, 49) == mcrfpy.Perspective.UNKNOWN
print(" PASS: at_after_transfer")
def test_set_grid_none():
"""entity.grid = None properly removes entity (tests #274)"""
grid = mcrfpy.Grid(grid_size=(10, 10))
grid = open_grid(10)
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
assert len(grid.entities) == 1
entity.grid = None
assert len(grid.entities) == 0
assert entity.grid is None
print(" PASS: set_grid_none")
def test_stress():
@ -150,26 +170,32 @@ def test_stress():
entity.grid = small_g
small_g.perspective = entity
entity.update_visibility()
assert entity.perspective_map.size == (5, 5)
big_g = mcrfpy.Grid(grid_size=(80, 80))
entity.grid = big_g
big_g.perspective = entity
entity.update_visibility()
assert entity.perspective_map.size == (80, 80)
frames = [mcrfpy.Frame() for _ in range(10)]
del frames
print(" PASS: stress")
print("Testing gridstate resize across transfer methods...")
test_set_grid()
test_append()
test_extend()
test_insert()
test_setitem()
test_slice_assign()
test_update_visibility_after_transfer()
test_at_after_transfer()
test_set_grid_none()
test_stress()
print("PASS: all gridstate resize tests passed")
print("Testing perspective map resize across transfer methods...")
try:
test_set_grid()
test_append()
test_extend()
test_insert()
test_setitem()
test_slice_assign()
test_update_visibility_after_transfer()
test_at_after_transfer()
test_set_grid_none()
test_stress()
except AssertionError as e:
print(f"FAIL: {e}")
sys.exit(1)
print("PASS: all perspective map resize tests passed")
sys.exit(0)

View file

@ -7,6 +7,14 @@ pointers would dangle.
Fix: Remove raw pointers. Store (grid, x, y) coordinates and compute
the data address on each property access.
API update (current contract): the per-entity GridPointState vector is gone.
Its successor is entity.perspective_map, a DiscreteMap of mcrfpy.Perspective
(UNKNOWN / DISCOVERED / VISIBLE), and entity.at(x, y) now returns the grid's
GridPoint only when that cell is VISIBLE to the entity (None otherwise).
The dangle scenario is preserved: the perspective map is reallocated when the
entity transfers to a differently-sized grid, and previously-obtained
GridPoint / DiscreteMap wrappers must remain safe to use afterward.
"""
import mcrfpy
import sys
@ -30,39 +38,63 @@ def test_gridpoint_grid_pos():
assert pos == (7, 3), f"Expected (7, 3), got {pos}"
print(" PASS: gridpoint_grid_pos")
def test_gridpointstate_access():
"""entity.at(x,y) returns working GridPointState via coordinate lookup"""
def test_perspective_map_access():
"""entity.at(x,y) / entity.perspective_map use coordinate lookup"""
grid = mcrfpy.Grid(grid_size=(10, 10))
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
entity.update_visibility()
state = entity.at(5, 5)
# Should be accessible
assert state is not None
# visible/discovered should be boolean
assert isinstance(state.visible, bool)
assert isinstance(state.discovered, bool)
print(" PASS: gridpointstate_access")
pm = entity.perspective_map
assert pm.size == (10, 10), f"Expected (10, 10), got {pm.size}"
def test_gridpointstate_after_grid_transfer():
"""GridPointState access works after entity transfers to new grid"""
# The entity's own cell is VISIBLE; entity.at() hands back the GridPoint.
assert pm.get((5, 5)) == mcrfpy.Perspective.VISIBLE
gp = entity.at(5, 5)
assert gp is not None
assert gp.grid_pos == (5, 5), f"Expected (5, 5), got {gp.grid_pos}"
# A cell outside the entity's FOV is UNKNOWN, and at() gates on visibility.
assert pm.get((9, 0)) == mcrfpy.Perspective.UNKNOWN
assert entity.at(9, 0) is None
print(" PASS: perspective_map_access")
def test_perspective_map_after_grid_transfer():
"""Perspective/GridPoint access works after entity transfers to new grid"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(20, 20))
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
entity.update_visibility()
# Get state on small grid
state1 = entity.at(3, 3)
assert state1 is not None
# Wrappers obtained while on the small grid
gp_small = small.grid_data.at(3, 3)
pm_small = entity.perspective_map
assert pm_small.size == (10, 10)
# Transfer to large grid (gridstate resizes)
# Transfer to large grid: the entity's perspective map is reallocated.
entity.grid = large
entity.update_visibility()
# Access a cell that didn't exist on small grid
state2 = entity.at(15, 15)
assert state2 is not None
print(" PASS: gridpointstate_after_grid_transfer")
# entity.grid is the shared GridData, not the view (#313/#361)
assert entity.grid is large.grid_data
# The new map covers the larger grid, and coordinate lookup reaches cells
# that did not exist on the small grid.
pm_large = entity.perspective_map
assert pm_large.size == (20, 20), f"Expected (20, 20), got {pm_large.size}"
assert pm_large.get((15, 15)) == mcrfpy.Perspective.UNKNOWN
assert pm_large.get((5, 5)) == mcrfpy.Perspective.VISIBLE
assert entity.at(5, 5) is not None
# The pre-transfer wrappers must not dangle: they still describe the small
# grid / old map and remain safe to read and write.
assert pm_small.size == (10, 10)
assert pm_small.get((3, 3)) in (mcrfpy.Perspective.UNKNOWN,
mcrfpy.Perspective.DISCOVERED,
mcrfpy.Perspective.VISIBLE)
assert gp_small.grid_pos == (3, 3)
gp_small.walkable = True
assert small.grid_data.at(3, 3).walkable == True
print(" PASS: perspective_map_after_grid_transfer")
def test_gridpoint_subscript():
"""grid[x, y] returns working GridPoint"""
@ -72,26 +104,33 @@ def test_gridpoint_subscript():
assert grid.at(3, 4).walkable == True
print(" PASS: gridpoint_subscript")
def test_gridstate_list():
"""entity.gridstate returns list with visible/discovered attrs"""
def test_perspective_map_all_cells():
"""entity.perspective_map covers every cell of the grid"""
grid = mcrfpy.Grid(grid_size=(5, 5))
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grid)
entity.update_visibility()
gs = entity.gridstate
assert len(gs) == 25, f"Expected 25, got {len(gs)}"
# Each element should have visible and discovered
for state in gs:
assert hasattr(state, 'visible')
assert hasattr(state, 'discovered')
print(" PASS: gridstate_list")
pm = entity.perspective_map
assert pm.size == (5, 5), f"Expected (5, 5), got {pm.size}"
print("Testing GridPoint/GridPointState coordinate-based access...")
# Every one of the 25 cells is addressable and holds a Perspective member
valid = (mcrfpy.Perspective.UNKNOWN, mcrfpy.Perspective.DISCOVERED,
mcrfpy.Perspective.VISIBLE)
count = 0
for y in range(5):
for x in range(5):
state = pm.get((x, y))
assert state in valid, f"({x}, {y}) has bad state {state}"
count += 1
assert count == 25, f"Expected 25, got {count}"
print(" PASS: perspective_map_all_cells")
print("Testing GridPoint/perspective_map coordinate-based access...")
test_gridpoint_access()
test_gridpoint_grid_pos()
test_gridpointstate_access()
test_gridpointstate_after_grid_transfer()
test_perspective_map_access()
test_perspective_map_after_grid_transfer()
test_gridpoint_subscript()
test_gridstate_list()
print("PASS: all GridPoint/GridPointState tests passed")
test_perspective_map_all_cells()
print("PASS: all GridPoint/perspective_map tests passed")
sys.exit(0)

View file

@ -0,0 +1,133 @@
"""
Regression test for issue #341.
get_metrics() reported 0 for every render counter:
* draw_calls / ui_elements / visible_elements WERE incremented -- but only inside
render(), which runs after every Python callback, while the counters were zeroed
at the top of the frame. Python was structurally unable to observe a nonzero
value. Render counters are now published at the end of the render pass.
* grid_cells_rendered / entities_rendered / total_entities / grid_render_time /
entity_render_time were never incremented ANYWHERE -- dead code since the
GridView refactor. Re-instrumented in UIGridView::render().
Also locks two riders: frame_time is milliseconds (the docstring claimed seconds),
and fps is no longer inflated by the zero-filled history buffer on early frames.
Model under test (#350): step() is the clock and never renders; rendering is
orthogonal and costs zero simulation time. So we drive time with step() and force a
render with a screenshot, then read the metrics that render published.
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
import tempfile
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
scene = mcrfpy.Scene("issue341")
ui = scene.children
# A grid with a layer + entities, so the grid counters have something to count.
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320))
layer = mcrfpy.TileLayer("ground", 0)
grid.add_layer(layer)
for x in range(20):
for y in range(20):
layer.set((x, y), 0)
for i in range(6):
grid.entities.append(mcrfpy.Entity(grid_pos=(i, i)))
ui.append(grid)
# Plain UI elements, so ui_elements / draw_calls have something to count.
for i in range(4):
ui.append(mcrfpy.Frame(pos=(400 + i * 20, 10), size=(15, 15)))
ui.append(mcrfpy.Caption(pos=(400, 200), text="metrics"))
mcrfpy.current_scene = scene
# Advance the clock a few times. step() must NOT render, so this alone must leave
# the render counters untouched.
for _ in range(5):
mcrfpy.step(0.016)
print("1. step() advances the sim clock but renders nothing")
m = mcrfpy.get_metrics()
check("current_frame advanced under step()", m["current_frame"] >= 5,
f"got {m['current_frame']}")
check("frame_time is set by step()", m["frame_time"] > 0, f"got {m['frame_time']}")
check("draw_calls still 0 (step does not render)", m["draw_calls"] == 0,
f"got {m['draw_calls']}")
# Force a render. This costs zero simulation time and publishes the render counters.
shot = os.path.join(tempfile.gettempdir(), "issue341_metrics.png")
frame_before = mcrfpy.get_metrics()["current_frame"]
automation.screenshot(shot)
m = mcrfpy.get_metrics()
print("2. Rendering costs zero simulation time")
check("current_frame did not advance from rendering",
m["current_frame"] == frame_before,
f"{frame_before} -> {m['current_frame']}")
print("3. Render counters are observable from Python (were always 0)")
check("draw_calls > 0", m["draw_calls"] > 0, f"got {m['draw_calls']}")
check("ui_elements > 0", m["ui_elements"] > 0, f"got {m['ui_elements']}")
check("visible_elements > 0", m["visible_elements"] > 0, f"got {m['visible_elements']}")
print("4. Grid counters are re-instrumented (were dead code)")
check("grid_cells_rendered > 0", m["grid_cells_rendered"] > 0,
f"got {m['grid_cells_rendered']}")
check("entities_rendered > 0", m["entities_rendered"] > 0,
f"got {m['entities_rendered']}")
check("total_entities == 6", m["total_entities"] == 6, f"got {m['total_entities']}")
check("entities_rendered <= total_entities",
m["entities_rendered"] <= m["total_entities"])
# A cell "render" is counted per cell per layer drawn: the 20x20 grid is fully on
# screen, so it is exactly 400 * (number of layers).
expected_cells = 400 * len(grid.layers)
check(f"grid_cells_rendered == 400 * {len(grid.layers)} layers",
m["grid_cells_rendered"] == expected_cells,
f"got {m['grid_cells_rendered']}, expected {expected_cells}")
print("5. frame_time is in milliseconds, not seconds")
ft = m["frame_time"]
# step(0.016) -> 16ms. In seconds this would read 0.016 and fail the lower bound.
check("frame_time ~= 16ms for step(0.016)", 10.0 <= ft <= 25.0, f"got {ft}")
print("6. fps is sane on early frames (was inflated by the zero-filled history)")
fps = m["fps"]
check("fps > 0", fps > 0, f"got {fps}")
# Pre-fix, dividing by a mostly-zero 60-slot buffer inflated this enormously.
# step(0.016) is 62.5 fps; allow slack but nothing absurd.
check("fps is not absurdly inflated", fps < 1000, f"got {fps}")
print("7. Timing breakdowns are present")
check("grid_render_time >= 0", m["grid_render_time"] >= 0.0)
check("entity_render_time >= 0", m["entity_render_time"] >= 0.0)
if os.path.exists(shot):
os.remove(shot)
print()
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -26,9 +26,13 @@ def main():
c.walkable = (i % 2 == 0)
assert g.at(0, 0).walkable is True
# entity.grid round-trips to the same Grid object
# entity.grid round-trips to the owning GridData wrapper.
# (#313/#361: entity.grid returns the shared GridData, NOT the Grid view.
# The #348 cache guarantee is what matters here: the same wrapper object
# comes back every time, and it is the *same* wrapper the view hands out.)
e = mcrfpy.Entity((1, 1), grid=g)
assert e.grid is g, "entity.grid should be the owning Grid"
assert e.grid is g.grid_data, "entity.grid should be the owning GridData"
assert e.grid is e.grid, "entity.grid must have stable wrapper identity"
# Reassigning grid_data invalidates the cache: new wrapper for new data
g2 = mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(40, 40))

View file

@ -0,0 +1,88 @@
"""
Regression test for issue #350.
Headless mcrfpy.step() advanced animations and timers only. It never called
McRFPy_API::updatePythonScenes(), which lives solely in doFrame() -- so under step():
* Scene.update(dt) overrides NEVER fired (the filed bug)
* the C++ scene update never ran
* scene transitions never progressed or completed
* current_frame never advanced
Any game with per-frame logic in update() was untestable headless.
step() is now a full SIMULATION frame: everything doFrame() does except render and
input. Rendering stays deliberately off the clock -- it costs zero simulation time
(see issue_341 test), so step() must still render nothing.
"""
import mcrfpy
import sys
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
# ------------------------------------------------------- Scene.update under step()
print("1. Scene.update(dt) fires under step() (the filed bug)")
class CountingScene(mcrfpy.Scene):
def __init__(self, name):
super().__init__(name)
self.update_count = 0
self.dt_total = 0.0
def update(self, dt):
self.update_count += 1
self.dt_total += dt
scene = CountingScene("issue350")
mcrfpy.current_scene = scene
for _ in range(5):
mcrfpy.step(0.016)
check("update() fired once per step()", scene.update_count == 5,
f"got {scene.update_count}")
check("update() received the dt", abs(scene.dt_total - 5 * 0.016) < 1e-4,
f"got {scene.dt_total}")
# ------------------------------------------------------------ current_frame advances
print("2. current_frame advances under step()")
before = mcrfpy.get_metrics()["current_frame"]
for _ in range(3):
mcrfpy.step(0.016)
after = mcrfpy.get_metrics()["current_frame"]
check("current_frame advanced by 3", after - before == 3, f"{before} -> {after}")
# ------------------------------------------------------------------- timers still fire
print("3. Timers still fire under step() (unchanged behavior)")
fired = []
mcrfpy.Timer("t350", lambda timer, rt: fired.append(rt), 50)
for _ in range(4):
mcrfpy.step(0.02)
check("timer fired", len(fired) > 0, f"got {len(fired)} fires")
# ----------------------------------------------------------- step() must not render
print("4. step() still renders nothing (render is off the clock)")
m = mcrfpy.get_metrics()
check("draw_calls == 0 after step()-only", m["draw_calls"] == 0,
f"got {m['draw_calls']}")
print()
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -0,0 +1,107 @@
"""
Regression test for issue #356.
mcrfpy's dynamic module attributes (current_scene, scenes, timers, ...) used to be a
PEP-562 module __getattr__. getattr/hasattr worked, but they were absent from
dir(mcrfpy) -- and every doc/stub/manifest generator discovers module symbols via
dir(), so the single most common idiom in the engine appeared in no generated docs.
They are now real getset descriptors on type(mcrfpy), plus a __dir__ override
(CPython's module.__dir__ reports only __dict__ keys, so descriptors alone are not
enough -- that is the trap this test exists to catch).
"""
import mcrfpy
import sys
import types
DYNAMIC = [
"current_scene",
"scenes",
"timers",
"animations",
"default_transition",
"default_transition_duration",
"save_dir",
]
READONLY = ["scenes", "timers", "animations", "save_dir"]
failures = []
def check(label, condition):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label}")
failures.append(label)
print("1. All dynamic attributes are in dir(mcrfpy)")
names = dir(mcrfpy)
for attr in DYNAMIC:
check(f"'{attr}' in dir(mcrfpy)", attr in names)
print("2. dir() still contains normal module symbols")
check("classes still listed", "Frame" in names and "Scene" in names)
check("functions still listed", "find" in names and "step" in names)
print("3. They are real descriptors carrying docstrings")
modtype = type(mcrfpy)
for attr in DYNAMIC:
descr = getattr(modtype, attr, None)
check(f"'{attr}' is a getset descriptor",
isinstance(descr, types.GetSetDescriptorType))
check(f"'{attr}' has a docstring", bool(descr and descr.__doc__))
print("4. Access still works (behavior preserved)")
scene = mcrfpy.Scene("issue356")
mcrfpy.current_scene = scene
check("current_scene round-trips", mcrfpy.current_scene is not None)
check("scenes is a tuple", isinstance(mcrfpy.scenes, tuple))
check("timers is a tuple", isinstance(mcrfpy.timers, tuple))
check("animations is a tuple", isinstance(mcrfpy.animations, tuple))
check("save_dir is a str", isinstance(mcrfpy.save_dir, str))
check("default_transition_duration is a float",
isinstance(mcrfpy.default_transition_duration, float))
mcrfpy.default_transition_duration = 0.25
check("default_transition_duration is writable",
abs(mcrfpy.default_transition_duration - 0.25) < 1e-6)
print("5. Read-only attributes still reject assignment")
for attr in READONLY:
try:
setattr(mcrfpy, attr, ())
check(f"assigning '{attr}' raises AttributeError", False)
except AttributeError:
check(f"assigning '{attr}' raises AttributeError", True)
print("6. Validation preserved on the writable ones")
try:
mcrfpy.default_transition_duration = -1.0
check("negative duration raises ValueError", False)
except ValueError:
check("negative duration raises ValueError", True)
try:
mcrfpy.default_transition_duration = "not a number"
check("non-numeric duration raises TypeError", False)
except TypeError:
check("non-numeric duration raises TypeError", True)
print("7. Unknown attributes still raise AttributeError")
try:
mcrfpy.definitely_not_an_attribute
check("unknown attribute raises AttributeError", False)
except AttributeError:
check("unknown attribute raises AttributeError", True)
print()
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -44,17 +44,14 @@ def shot():
def case_child_parent_is_the_view():
"""The child's parent is the Grid (UIGridView), not the internal _GridData.
Identity (`child.parent is grid`) is NOT asserted: `.parent` allocates a fresh
wrapper on every read, so `is` fails for Frames too -- even `kid.parent is
kid.parent` is False. That is a pre-existing engine-wide gap, not this bug, and
it is filed separately. Here we assert the parent's TYPE and identify the view by
name, which is what #364 actually turns on.
Identity is asserted directly now that #369 routes .parent through the object
cache; this originally had to settle for comparing .name.
"""
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200), name="the_view")
bubble = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
grid.children.append(bubble)
check("1a: child.parent is the grid view", bubble.parent.name == "the_view")
check("1a: child.parent is the grid view", bubble.parent is grid)
# #361: mcrfpy.Grid IS mcrfpy.GridView (one type object, two names), and the
# canonical tp_name is GridView.
check("1b: child.parent is a Grid, not a GridData",
@ -72,7 +69,7 @@ def case_children_are_per_view_entities_are_shared():
v1.children.append(marker)
check("2b: child appended to v1 is in v1.children", len(v1.children) == 1)
check("2c: child appended to v1 is NOT in v2.children", len(v2.children) == 0)
check("2d: the child's parent is v1, not v2", marker.parent.name == "v1")
check("2d: the child's parent is v1, not v2", marker.parent is v1)
v2.children.append(mcrfpy.Frame(pos=(64, 64), size=(20, 20)))
check("2e: v2 keeps its own children", len(v2.children) == 1 and len(v1.children) == 1)

View file

@ -0,0 +1,136 @@
"""
Regression test for issue #369.
Every path that hands a C++ drawable back to Python must return the *same* wrapper
object, or `child.parent is parent` silently answers False and Python subclasses
reached through those paths lose their identity.
Broken paths this locks down:
- UIDrawable::get_parent (.parent)
- find_in_collection (mcrfpy.find / find_all)
- UIEntityCollection concat/slice (entities + [...], entities[a:b])
"""
import mcrfpy
import sys
failures = []
def check(label, condition):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label}")
failures.append(label)
scene = mcrfpy.Scene("issue369")
ui = scene.children
# ---------------------------------------------------------------- .parent identity
print("1. .parent returns a stable, identical wrapper")
parent = mcrfpy.Frame(pos=(0, 0), size=(100, 100), name="parent_frame")
kid = mcrfpy.Frame(pos=(1, 1), size=(10, 10), name="kid_frame")
ui.append(parent)
parent.children.append(kid)
check("kid.parent is parent", kid.parent is parent)
check("repeated reads are identical", kid.parent is kid.parent)
check("id() is stable across reads", id(kid.parent) == id(kid.parent))
# .parent must be usable as a dict key / set member
seen = {kid.parent: "found"}
check(".parent works as a dict key", seen.get(parent) == "found")
check(".parent works in a set", len({kid.parent, kid.parent, parent}) == 1)
# ------------------------------------------------------- subclass survives .parent
print("2. Python subclass identity survives .parent")
class MyFrame(mcrfpy.Frame):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.custom_state = "preserved"
sub_parent = MyFrame(pos=(0, 0), size=(200, 200), name="sub_parent")
sub_kid = mcrfpy.Caption(pos=(5, 5), text="inner", name="sub_kid")
ui.append(sub_parent)
sub_parent.children.append(sub_kid)
check("sub_kid.parent is sub_parent", sub_kid.parent is sub_parent)
check("subclass type preserved", isinstance(sub_kid.parent, MyFrame))
check("subclass attributes preserved",
getattr(sub_kid.parent, "custom_state", None) == "preserved")
# ------------------------------------------------- .parent covers all drawable types
print("3. .parent resolves non-Frame/Caption/Sprite/Grid parents (was None)")
# Line/Circle/Arc/Viewport3D had no switch arm and fell through to Py_RETURN_NONE.
# They aren't containers, so verify the *child-of-grid* case instead: a GridView
# parent must come back identical, and every type must at least round-trip via find().
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(100, 100), name="the_grid")
ui.append(grid)
overlay = mcrfpy.Frame(pos=(0, 0), size=(10, 10), name="overlay_child")
grid.children.append(overlay)
check("overlay.parent is grid", overlay.parent is grid)
# ------------------------------------------------------------- find() identity
print("4. find() / find_all() return existing wrappers")
found = mcrfpy.find("parent_frame", "issue369")
check("find() returns the same object", found is parent)
found_sub = mcrfpy.find("sub_parent", "issue369")
check("find() preserves subclass", found_sub is sub_parent)
check("find() subclass attrs intact",
getattr(found_sub, "custom_state", None) == "preserved")
all_found = mcrfpy.find_all("*_frame", "issue369")
by_id = {id(o) for o in all_found}
check("find_all() returns live wrappers",
id(parent) in by_id and id(kid) in by_id)
# ---------------------------------------------------------- entity collection paths
print("5. EntityCollection slice / concat preserve entity identity")
class MyEntity(mcrfpy.Entity):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.tag = "mine"
e0 = MyEntity(grid_pos=(1, 1))
e1 = mcrfpy.Entity(grid_pos=(2, 2))
e2 = mcrfpy.Entity(grid_pos=(3, 3))
for e in (e0, e1, e2):
grid.entities.append(e)
check("entities[0] is e0", grid.entities[0] is e0)
sliced = grid.entities[0:2]
check("slice returns the same wrappers", sliced[0] is e0 and sliced[1] is e1)
check("slice preserves subclass", isinstance(sliced[0], MyEntity))
check("slice preserves subclass attrs", getattr(sliced[0], "tag", None) == "mine")
concat = grid.entities + []
check("concat returns the same wrappers", concat[0] is e0)
check("concat preserves subclass", isinstance(concat[0], MyEntity))
# The #266 identity ref must survive a duplicate-wrapper round trip: before the fix,
# the temporary wrapper's dealloc cleared UIEntity::pyobject on the shared C++ object.
del sliced, concat
import gc
gc.collect()
check("entity identity survives temp wrappers", grid.entities[0] is e0)
check("subclass attrs survive temp wrappers",
getattr(grid.entities[0], "tag", None) == "mine")
print()
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
Issue #373: a GC'd Python subclass wrapper must not resurrect as its base type.
PythonObjectCache holds only weakrefs, so #369 (route every C++ -> Python conversion
through the cache) preserved object identity only while Python still held a reference
to the wrapper. If the user dropped their last reference while C++ still owned the
object, the wrapper was collected, the next lookup missed the cache, and a fresh
BASE-type wrapper was allocated -- silently downgrading the subclass and losing every
attribute set on it.
The fix is an owner-held strong ref ("pin"): a subclassed drawable's wrapper is kept
alive for exactly as long as the drawable is a member of a children collection. That
boundary is the parent link, so the pin is taken in setParent()/setParentScene() and
released on every path out -- including an owner destroyed while it still holds
children, which is the leak this test also guards.
"""
import gc
import sys
import weakref
import mcrfpy
failures = []
def check(cond, msg):
if not cond:
failures.append(msg)
print(f"FAIL: {msg}")
else:
print(f" ok: {msg}")
class MyFrame(mcrfpy.Frame):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.hp = 100
class MyCaption(mcrfpy.Caption):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.tag = "captioned"
def test_parent_survives_gc():
"""The scenario from the issue: reach a GC'd subclass parent through .parent."""
scene = mcrfpy.Scene("gc_parent")
parent = MyFrame(pos=(0, 0), size=(100, 100))
scene.children.append(parent)
kid = mcrfpy.Frame(pos=(1, 1), size=(10, 10))
parent.children.append(kid)
del parent # C++ still owns it via the scene's collection
gc.collect()
recovered = kid.parent
check(type(recovered) is MyFrame,
f"kid.parent is still a MyFrame after gc (got {type(recovered).__name__})")
check(getattr(recovered, "hp", None) == 100,
"the subclass attribute set in __init__ survived")
check(kid.parent is kid.parent,
"repeated .parent reads return the same object")
check(kid.parent is recovered,
"the pinned wrapper is the one handed back, not a new one")
def test_collection_indexing_survives_gc():
"""Reaching the same object by indexing the collection, not via .parent."""
scene = mcrfpy.Scene("gc_index")
sub = MyCaption(text="hello", pos=(5, 5))
scene.children.append(sub)
ref = weakref.ref(sub)
del sub
gc.collect()
check(ref() is not None,
"a subclassed drawable in a scene keeps its wrapper alive")
got = scene.children[0]
check(type(got) is MyCaption,
f"scene.children[0] is still a MyCaption (got {type(got).__name__})")
check(got.tag == "captioned", "its subclass attribute survived")
check(got is ref(), "it is the original wrapper, not a copy")
def test_pin_released_on_removal():
"""Leaving the collection must drop the pin, or every subclass leaks forever."""
scene = mcrfpy.Scene("gc_release")
sub = MyFrame(pos=(0, 0), size=(10, 10))
scene.children.append(sub)
ref = weakref.ref(sub)
scene.children.remove(sub)
del sub
gc.collect()
check(ref() is None,
"the wrapper is freed once the drawable leaves the collection")
def test_pin_released_when_owner_dies():
"""An owner destroyed while still holding children must drop their pins.
Nothing calls setParent(nullptr) on this path -- the children vector simply dies --
so without an explicit release in ~UIFrame the wrapper <-> drawable cycle would
strand the whole subtree in memory.
"""
scene = mcrfpy.Scene("gc_owner")
holder = mcrfpy.Frame(pos=(0, 0), size=(50, 50))
scene.children.append(holder)
inner = MyFrame(pos=(1, 1), size=(5, 5))
holder.children.append(inner)
ref = weakref.ref(inner)
del inner
gc.collect()
check(ref() is not None, "a nested subclass is pinned while its owner holds it")
scene.children.remove(holder)
del holder
gc.collect()
check(ref() is None,
"the nested wrapper is freed when its owner is destroyed (no leak)")
def test_reparenting_preserves_identity():
"""Moving between collections must not drop the subclass on the floor.
Reparenting unlinks then relinks; if the unlink released the last reference, the
re-pin would find nothing in the cache and the subclass would be gone.
"""
scene = mcrfpy.Scene("gc_reparent")
a = mcrfpy.Frame(pos=(0, 0), size=(80, 80))
b = mcrfpy.Frame(pos=(0, 0), size=(80, 80))
scene.children.append(a)
scene.children.append(b)
sub = MyFrame(pos=(2, 2), size=(8, 8))
sub.hp = 55
a.children.append(sub)
ref = weakref.ref(sub)
del sub
gc.collect()
moved = a.children[0]
b.children.append(moved) # reparent a -> b
del moved
gc.collect()
check(len(a.children) == 0, "the drawable left its old parent")
check(len(b.children) == 1, "the drawable joined its new parent")
check(ref() is not None, "it is still pinned by its new owner")
got = b.children[0]
check(type(got) is MyFrame,
f"it is still a MyFrame after reparenting (got {type(got).__name__})")
check(got.hp == 55, "and kept the attribute that was set on it")
def test_base_type_is_not_pinned():
"""Only subclasses are pinned. A base wrapper carries no state, so re-creating it
is unobservable -- and pinning it would cost memory for nothing."""
scene = mcrfpy.Scene("gc_base")
plain = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
scene.children.append(plain)
ref = weakref.ref(plain)
del plain
gc.collect()
check(ref() is None, "a base-type wrapper is not pinned")
got = scene.children[0]
check(type(got) is mcrfpy.Frame, "it still round-trips as a Frame")
check(got.w == 10, "and its C++ state is intact")
def main():
test_parent_survives_gc()
test_collection_indexing_survives_gc()
test_pin_released_on_removal()
test_pin_released_when_owner_dies()
test_reparenting_preserves_identity()
test_base_type_is_not_pinned()
if failures:
print(f"\nFAILED ({len(failures)} checks)")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Issue #377: every UICollection mutator must maintain the parent link.
`x in collection` and `x.parent is <collection owner>` are supposed to be two views of
the same fact (#122/#183). Only append() maintained it. The rest got it wrong two ways:
* insert/extend/setitem called setParent(owner.lock()) unconditionally. A SCENE
collection has no owner drawable, so that resolved to null -- and setParent(nullptr)
also clears parent_scene. The drawable landed in the scene's UI vector, rendered
fine, and reported .parent of None.
* The slice arms never touched the link at all: a slice-delete left the removed child
pointing at a parent that no longer contained it, and a slice-assign never parented
the incoming items nor unparented the ones they displaced.
Two adjacent traps in the same functions are also covered here: `collection[-1]` on an
EMPTY collection hung the engine forever (`while (index < 0) index += size()` adds zero
each pass), and getitem's `index > size() - 1` bounds check underflowed to SIZE_MAX when
size() was 0, so it went on to index an empty vector.
This invariant is what #373's identity pin hangs off, so it has to hold for every path.
"""
import sys
import mcrfpy
failures = []
def check(cond, msg):
if not cond:
failures.append(msg)
print(f"FAIL: {msg}")
else:
print(f" ok: {msg}")
def frame(n=10):
return mcrfpy.Frame(pos=(0, 0), size=(n, n))
def check_linked(child, owner, collection, label):
"""The two views of membership must agree."""
check(child in collection, f"{label}: child is in the collection")
check(child.parent is owner, f"{label}: child.parent is the owner (got {child.parent!r})")
def check_unlinked(child, collection, label):
check(child not in collection, f"{label}: child is NOT in the collection")
check(child.parent is None, f"{label}: child.parent is None (got {child.parent!r})")
def test_scene_collection_mutators():
"""A scene collection parents via parent_scene; only append used to do it."""
scene = mcrfpy.Scene("link_scene")
a, b, c, d = frame(), frame(), frame(), frame()
scene.children.append(a)
check_linked(a, scene, scene.children, "scene append")
scene.children.insert(0, b)
check_linked(b, scene, scene.children, "scene insert")
scene.children.extend([c])
check_linked(c, scene, scene.children, "scene extend")
scene.children[0] = d
check_linked(d, scene, scene.children, "scene setitem")
def test_frame_collection_mutators():
scene = mcrfpy.Scene("link_frame")
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
scene.children.append(owner)
a, b, c, d = frame(), frame(), frame(), frame()
owner.children.append(a)
check_linked(a, owner, owner.children, "frame append")
owner.children.insert(0, b)
check_linked(b, owner, owner.children, "frame insert")
owner.children.extend([c])
check_linked(c, owner, owner.children, "frame extend")
replaced = owner.children[0]
owner.children[0] = d
check_linked(d, owner, owner.children, "frame setitem")
check_unlinked(replaced, owner.children, "frame setitem (displaced element)")
def test_removal_mutators_unlink():
scene = mcrfpy.Scene("link_remove")
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
scene.children.append(owner)
a, b, c = frame(), frame(), frame()
owner.children.extend([a, b, c])
owner.children.remove(a)
check_unlinked(a, owner.children, "frame remove()")
popped = owner.children.pop(0)
check_unlinked(popped, owner.children, "frame pop()")
del owner.children[0]
check_unlinked(c, owner.children, "frame __delitem__")
check(len(owner.children) == 0, "the collection is empty after removing everything")
def test_slice_delete_unlinks():
scene = mcrfpy.Scene("link_slicedel")
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
scene.children.append(owner)
a, b, c, d = frame(), frame(), frame(), frame()
owner.children.extend([a, b, c, d])
del owner.children[0:2] # contiguous
check(len(owner.children) == 2, "contiguous slice-delete removed two elements")
check_unlinked(a, owner.children, "contiguous slice-delete (a)")
check_unlinked(b, owner.children, "contiguous slice-delete (b)")
check_linked(c, owner, owner.children, "contiguous slice-delete (survivor c)")
e, f = frame(), frame()
owner.children.extend([e, f]) # now: c, d, e, f
del owner.children[0::2] # extended slice: removes c and e
check_unlinked(c, owner.children, "extended slice-delete (c)")
check_unlinked(e, owner.children, "extended slice-delete (e)")
check_linked(d, owner, owner.children, "extended slice-delete (survivor d)")
check_linked(f, owner, owner.children, "extended slice-delete (survivor f)")
def test_slice_assign_links():
scene = mcrfpy.Scene("link_sliceassign")
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
scene.children.append(owner)
a, b = frame(), frame()
owner.children.extend([a, b])
c, d = frame(), frame()
owner.children[0:1] = [c, d] # resizing contiguous assign
check_linked(c, owner, owner.children, "slice-assign (new element c)")
check_linked(d, owner, owner.children, "slice-assign (new element d)")
check_unlinked(a, owner.children, "slice-assign (displaced element a)")
check_linked(b, owner, owner.children, "slice-assign (untouched element b)")
e = frame()
owner.children[0:1] = [e] # same-size contiguous assign
check_linked(e, owner, owner.children, "same-size slice-assign (new element)")
check_unlinked(c, owner.children, "same-size slice-assign (displaced element)")
# Extended slice assign
g, h = frame(), frame()
owner.children[0::2] = [g, h]
check_linked(g, owner, owner.children, "extended slice-assign (g)")
check_linked(h, owner, owner.children, "extended slice-assign (h)")
def test_slice_assign_on_scene():
"""Slice-assign into a scene collection must parent to the SCENE."""
scene = mcrfpy.Scene("link_slicescene")
a, b = frame(), frame()
scene.children.extend([a, b])
c = frame()
scene.children[0:1] = [c]
check_linked(c, scene, scene.children, "scene slice-assign")
check_unlinked(a, scene.children, "scene slice-assign (displaced)")
def test_reparent_between_owners():
"""Appending to a new owner must remove from the old one, not leave it in both."""
scene = mcrfpy.Scene("link_reparent")
one = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
two = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
scene.children.extend([one, two])
x = frame()
one.children.append(x)
check_linked(x, one, one.children, "child starts in owner one")
two.children.append(x)
check(x not in one.children, "reparenting removed the child from its old owner")
check_linked(x, two, two.children, "reparenting linked the child to its new owner")
def test_empty_collection_negative_index():
"""`collection[-1]` on an empty collection used to hang the engine forever."""
scene = mcrfpy.Scene("link_empty")
try:
scene.children[-1]
check(False, "getitem[-1] on an empty collection raises IndexError")
except IndexError:
check(True, "getitem[-1] on an empty collection raises IndexError")
try:
scene.children[-1] = frame()
check(False, "setitem[-1] on an empty collection raises IndexError")
except IndexError:
check(True, "setitem[-1] on an empty collection raises IndexError")
# Negative indexing still works when there IS something to index.
a, b = frame(), frame()
scene.children.extend([a, b])
check(scene.children[-1] is b, "negative indexing resolves from the end")
check(scene.children[-2] is a, "negative indexing reaches the first element")
try:
scene.children[-3]
check(False, "an out-of-range negative index raises IndexError")
except IndexError:
check(True, "an out-of-range negative index raises IndexError")
def main():
test_scene_collection_mutators()
test_frame_collection_mutators()
test_removal_mutators_unlink()
test_slice_delete_unlinks()
test_slice_assign_links()
test_slice_assign_on_scene()
test_reparent_between_owners()
test_empty_collection_negative_index()
if failures:
print(f"\nFAILED ({len(failures)} checks)")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -1,220 +1,246 @@
#!/usr/bin/env python3
"""
Comprehensive test for Issue #9: Recreate RenderTexture when UIGrid is resized
Regression test for Issue #9: Recreate RenderTexture when a grid view is resized
This test demonstrates that UIGrid has a hardcoded RenderTexture size of 1920x1080,
which causes rendering issues when the grid is resized beyond these dimensions.
The bug: UIGrid::render() created its RenderTexture once, at a hardcoded size, and
never recreated it. Resizing the grid widget beyond that texture left the new area
unrendered (clipped), and shrinking it left stale content blitted outside the box.
The bug: UIGrid::render() creates a RenderTexture with fixed size (1920x1080) once,
but never recreates it when the grid is resized, causing clipping and rendering artifacts.
The fix (UIGridView::ensureRenderTextureSize()) sizes the RenderTexture from the game
resolution and recreates it whenever that changes; the blit is clipped to the widget
box. So today a grid must render *correctly across its whole box at every size*:
* enlarging the widget renders content in the newly exposed area (was clipped),
* shrinking it leaves no stale pixels outside the new box,
* content never spills past the widget bounds,
* a widget larger than the window still renders its visible portion.
This test asserts those four properties on real screenshot pixels. The old version of
this file only printed "check the screenshots by eye" and, worse, died in setup on
grid.at(x, y).color (removed -- per-cell color lives on a ColorLayer now), so none of
it ever ran.
API notes for the update: GridPoint has no .color -> mcrfpy.ColorLayer; Grid takes
grid_size=/pos=/size= kwargs; mcrfpy.setScene/sceneUI -> mcrfpy.Scene + scene.children;
step() is the clock and never renders, automation.screenshot() forces the render.
Window.resolution cannot change in headless, so the resolution-driven recreation path
is exercised via widget resizes only.
"""
import mcrfpy
from mcrfpy import automation
import struct
import sys
import os
import zlib
def create_checkerboard_pattern(grid, grid_width, grid_height, cell_size=2):
"""Create a checkerboard pattern on the grid for visibility"""
FAILURES = []
# Grid data is large enough (160 x 160 cells) that its content covers every widget
# size used below, so "no content here" always means a rendering failure.
GRID_CELLS = 160
WHITE = (255, 255, 255)
GRAY = (100, 100, 100)
RED = (255, 0, 0)
BACKGROUND = (0, 0, 0) # scene clear color, outside any grid
CONTENT_COLORS = (WHITE, GRAY, RED)
def check(label, condition, detail=""):
if condition:
print(" PASS: %s" % label)
else:
print(" FAIL: %s %s" % (label, detail))
FAILURES.append(label)
# --------------------------------------------------------------------------- #
# Minimal PNG reader (stdlib only), same as tests/unit/validate_screenshot_test.py
# --------------------------------------------------------------------------- #
def read_png(path):
with open(path, "rb") as f:
data = f.read()
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
pos = 8
idat = b""
width = height = depth = color = None
while pos < len(data):
(length,) = struct.unpack(">I", data[pos:pos + 4])
ctype = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
pos += 12 + length
if ctype == b"IHDR":
width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", chunk)
assert depth == 8, "unexpected bit depth %d" % depth
assert color in (2, 6), "unexpected color type %d" % color
assert interlace == 0, "interlaced PNG not supported"
elif ctype == b"IDAT":
idat += chunk
elif ctype == b"IEND":
break
raw = zlib.decompress(idat)
channels = 3 if color == 2 else 4
stride = width * channels
out = bytearray(height * stride)
prev = bytearray(stride)
p = 0
for y in range(height):
filt = raw[p]
p += 1
line = bytearray(raw[p:p + stride])
p += stride
if filt == 1: # Sub
for i in range(channels, stride):
line[i] = (line[i] + line[i - channels]) & 0xFF
elif filt == 2: # Up
for i in range(stride):
line[i] = (line[i] + prev[i]) & 0xFF
elif filt == 3: # Average
for i in range(stride):
a = line[i - channels] if i >= channels else 0
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
elif filt == 4: # Paeth
for i in range(stride):
a = line[i - channels] if i >= channels else 0
b = prev[i]
c = prev[i - channels] if i >= channels else 0
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 0xFF
elif filt != 0:
raise AssertionError("bad PNG filter %d" % filt)
out[y * stride:(y + 1) * stride] = line
prev = line
return width, height, channels, bytes(out)
class Image:
def __init__(self, path):
self.w, self.h, self.ch, self.px = read_png(path)
def at(self, x, y):
i = (y * self.w + x) * self.ch
return tuple(self.px[i:i + 3])
def create_checkerboard_pattern(layer, grid_width, grid_height, cell_size=2):
"""Create a checkerboard pattern on the color layer for visibility"""
for x in range(grid_width):
for y in range(grid_height):
if (x // cell_size + y // cell_size) % 2 == 0:
grid.at(x, y).color = mcrfpy.Color(255, 255, 255, 255) # White
layer.set((x, y), mcrfpy.Color(*WHITE))
else:
grid.at(x, y).color = mcrfpy.Color(100, 100, 100, 255) # Gray
layer.set((x, y), mcrfpy.Color(*GRAY))
def add_border_markers(grid, grid_width, grid_height):
def add_border_markers(layer, grid_width, grid_height):
"""Add colored markers at the borders to test rendering limits"""
# Red border on top
for x in range(grid_width):
grid.at(x, 0).color = mcrfpy.Color(255, 0, 0, 255)
# Green border on right
layer.set((x, 0), mcrfpy.Color(*RED))
layer.set((x, grid_height - 1), mcrfpy.Color(*RED))
for y in range(grid_height):
grid.at(grid_width-1, y).color = mcrfpy.Color(0, 255, 0, 255)
layer.set((0, y), mcrfpy.Color(*RED))
layer.set((grid_width - 1, y), mcrfpy.Color(*RED))
# Blue border on bottom
for x in range(grid_width):
grid.at(x, grid_height-1).color = mcrfpy.Color(0, 0, 255, 255)
# Yellow border on left
for y in range(grid_height):
grid.at(0, y).color = mcrfpy.Color(255, 255, 0, 255)
def render(path):
"""step() advances the sim; the screenshot is what forces a render."""
mcrfpy.step(0.01)
automation.screenshot(path)
return Image(path)
def anchor_camera(grid):
"""Keep tile (0,0) at the widget's top-left after a resize (#169 default)."""
grid.center = (grid.w / 2.0, grid.h / 2.0)
def is_content(px):
return px in CONTENT_COLORS
print("=== Testing grid RenderTexture resize (Issue #9) ===\n")
# Set up the test scene
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
print("=== Testing UIGrid RenderTexture Resize (Issue #9) ===\n")
scene_ui = test.children
# Test 1: Small grid (should work fine)
print("--- Test 1: Small Grid (400x300) ---")
grid1 = mcrfpy.Grid(20, 15) # 20x15 tiles
grid1.x = 10
grid1.y = 10
grid1.w = 400
grid1.h = 300
scene_ui.append(grid1)
GRID_X, GRID_Y = 10, 10
grid = mcrfpy.Grid(grid_size=(GRID_CELLS, GRID_CELLS), pos=(GRID_X, GRID_Y), size=(200, 150))
colors = mcrfpy.ColorLayer(name="cells")
grid.add_layer(colors)
create_checkerboard_pattern(colors, GRID_CELLS, GRID_CELLS)
add_border_markers(colors, GRID_CELLS, GRID_CELLS)
scene_ui.append(grid)
create_checkerboard_pattern(grid1, 20, 15)
add_border_markers(grid1, 20, 15)
# --- Test 1: small grid renders its whole box, and nothing outside it ---------
print("--- Test 1: Small Grid (200x150) ---")
anchor_camera(grid)
img = render("/tmp/issue_9_small_grid.png")
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_small_grid.png")
print("PASS: Small grid created and rendered")
check("top-left tile of small grid is the red border marker",
img.at(GRID_X + 2, GRID_Y + 2) == RED,
"got %s" % (img.at(GRID_X + 2, GRID_Y + 2),))
check("interior of small grid is rendered content",
is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),))
check("far corner of small grid box is rendered content",
is_content(img.at(GRID_X + 195, GRID_Y + 145)),
"got %s" % (img.at(GRID_X + 195, GRID_Y + 145),))
check("nothing rendered outside the small grid box",
img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),))
# Test 2: Medium grid at 1920x1080 limit
print("\n--- Test 2: Medium Grid at 1920x1080 Limit ---")
grid2 = mcrfpy.Grid(64, 36) # 64x36 tiles at 30px each = 1920x1080
grid2.x = 10
grid2.y = 320
grid2.w = 1920
grid2.h = 1080
scene_ui.append(grid2)
# --- Test 2: enlarging the widget must render the newly exposed area ----------
# This is the heart of #9: (500,400) and (880,690) were outside the previous render
# area. With a stale RenderTexture they stay blank/clipped.
print("\n--- Test 2: Resize 200x150 -> 900x700 ---")
grid.w = 900
grid.h = 700
anchor_camera(grid)
img = render("/tmp/issue_9_resized.png")
create_checkerboard_pattern(grid2, 64, 36, 4)
add_border_markers(grid2, 64, 36)
check("area exposed by the resize is rendered (500,400)",
is_content(img.at(500, 400)), "got %s" % (img.at(500, 400),))
check("far corner of the enlarged box is rendered (880,690)",
is_content(img.at(880, 690)), "got %s" % (img.at(880, 690),))
check("enlarged grid does not spill past its right edge",
img.at(GRID_X + 900 + 5, 100) == BACKGROUND,
"got %s" % (img.at(GRID_X + 900 + 5, 100),))
check("enlarged grid does not spill past its bottom edge",
img.at(100, GRID_Y + 700 + 5) == BACKGROUND,
"got %s" % (img.at(100, GRID_Y + 700 + 5),))
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_limit_grid.png")
print("PASS: Grid at RenderTexture limit created")
# --- Test 3: widget larger than the window still renders its visible portion --
print("\n--- Test 3: Grid larger than the window (2400x1400) ---")
grid.w = 2400
grid.h = 1400
anchor_camera(grid)
img = render("/tmp/issue_9_beyond_window.png")
# Test 3: Resize grid1 beyond limits
print("\n--- Test 3: Resizing Small Grid Beyond 1920x1080 ---")
print("Original size: 400x300")
grid1.w = 2400
grid1.h = 1400
print(f"Resized to: {grid1.w}x{grid1.h}")
check("oversized grid renders at the far edge of the window (1000,700)",
is_content(img.at(1000, 700)), "got %s" % (img.at(1000, 700),))
check("oversized grid renders near the window origin (20,20)",
is_content(img.at(20, 20)), "got %s" % (img.at(20, 20),))
# The content should still be visible but may be clipped
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_resized_beyond_limit.png")
print("EXPECTED ISSUE: Grid resized beyond RenderTexture limits")
print(" Content beyond 1920x1080 will be clipped!")
# --- Test 4: shrinking must not leave stale pixels outside the new box --------
print("\n--- Test 4: Shrink back to 200x150 ---")
grid.w = 200
grid.h = 150
anchor_camera(grid)
img = render("/tmp/issue_9_shrunk.png")
# Test 4: Create large grid from start
print("\n--- Test 4: Large Grid from Start (2400x1400) ---")
# Clear previous grids
while len(scene_ui) > 0:
scene_ui.remove(0)
check("shrunken grid still renders inside its box",
is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),))
check("no stale content left outside the shrunken box (500,400)",
img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),))
check("no stale content left outside the shrunken box (880,690)",
img.at(880, 690) == BACKGROUND, "got %s" % (img.at(880, 690),))
grid3 = mcrfpy.Grid(80, 50) # Large tile count
grid3.x = 10
grid3.y = 10
grid3.w = 2400
grid3.h = 1400
scene_ui.append(grid3)
create_checkerboard_pattern(grid3, 80, 50, 5)
add_border_markers(grid3, 80, 50)
# Add markers at specific positions to test rendering
# Mark the center
center_x, center_y = 40, 25
for dx in range(-2, 3):
for dy in range(-2, 3):
grid3.at(center_x + dx, center_y + dy).color = mcrfpy.Color(255, 0, 255, 255) # Magenta
# Mark position at 1920 pixel boundary (64 tiles * 30 pixels/tile = 1920)
if 64 < 80: # Only if within grid bounds
for y in range(min(50, 10)):
grid3.at(64, y).color = mcrfpy.Color(255, 128, 0, 255) # Orange
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_large_grid.png")
print("EXPECTED ISSUE: Large grid created")
print(" Content beyond 1920x1080 will not render!")
print(" Look for missing orange line at x=1920 boundary")
# Test 5: Dynamic resize test
print("\n--- Test 5: Dynamic Resize Test ---")
scene_ui.remove(0)
grid4 = mcrfpy.Grid(100, 100)
grid4.x = 10
grid4.y = 10
scene_ui.append(grid4)
sizes = [(500, 500), (1000, 1000), (1500, 1500), (2000, 2000), (2500, 2500)]
for i, (w, h) in enumerate(sizes):
grid4.w = w
grid4.h = h
# Add pattern at current size
visible_tiles_x = min(100, w // 30)
visible_tiles_y = min(100, h // 30)
# Clear and create new pattern
for x in range(visible_tiles_x):
for y in range(visible_tiles_y):
if x == visible_tiles_x - 1 or y == visible_tiles_y - 1:
# Edge markers
grid4.at(x, y).color = mcrfpy.Color(255, 255, 0, 255)
elif (x + y) % 10 == 0:
# Diagonal lines
grid4.at(x, y).color = mcrfpy.Color(0, 255, 255, 255)
mcrfpy.step(0.01)
automation.screenshot(f"/tmp/issue_9_resize_{w}x{h}.png")
if w > 1920 or h > 1080:
print(f"FAIL: Size {w}x{h}: Content clipped at 1920x1080")
else:
print(f"PASS: Size {w}x{h}: Rendered correctly")
# Test 6: Verify exact clipping boundary
print("\n--- Test 6: Exact Clipping Boundary Test ---")
scene_ui.remove(0)
grid5 = mcrfpy.Grid(70, 40)
grid5.x = 0
grid5.y = 0
grid5.w = 2100 # 70 * 30 = 2100 pixels
grid5.h = 1200 # 40 * 30 = 1200 pixels
scene_ui.append(grid5)
# Create a pattern that shows the boundary clearly
for x in range(70):
for y in range(40):
pixel_x = x * 30
pixel_y = y * 30
if pixel_x == 1920 - 30: # Last tile before boundary
grid5.at(x, y).color = mcrfpy.Color(255, 0, 0, 255) # Red
elif pixel_x == 1920: # First tile after boundary
grid5.at(x, y).color = mcrfpy.Color(0, 255, 0, 255) # Green
elif pixel_y == 1080 - 30: # Last row before boundary
grid5.at(x, y).color = mcrfpy.Color(0, 0, 255, 255) # Blue
elif pixel_y == 1080: # First row after boundary
grid5.at(x, y).color = mcrfpy.Color(255, 255, 0, 255) # Yellow
else:
# Normal checkerboard
if (x + y) % 2 == 0:
grid5.at(x, y).color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_boundary_test.png")
print("Screenshot saved showing clipping boundary")
print("- Red tiles: Last visible column (x=1890-1919)")
print("- Green tiles: First clipped column (x=1920+)")
print("- Blue tiles: Last visible row (y=1050-1079)")
print("- Yellow tiles: First clipped row (y=1080+)")
# Summary
print("\n=== SUMMARY ===")
print("Issue #9: UIGrid uses a hardcoded RenderTexture size of 1920x1080")
print("Problems demonstrated:")
print("1. Grids larger than 1920x1080 are clipped")
print("2. Resizing grids doesn't recreate the RenderTexture")
print("3. Content beyond the boundary is not rendered")
print("\nThe fix should:")
print("1. Recreate RenderTexture when grid size changes")
print("2. Use the actual grid dimensions instead of hardcoded values")
print("3. Consider memory limits for very large grids")
if FAILURES:
print("FAIL: %d check(s) failed: %s" % (len(FAILURES), ", ".join(FAILURES)))
sys.exit(1)
print(f"\nScreenshots saved to /tmp/issue_9_*.png")
print("\nTest complete - check screenshots for visual verification")
print("Screenshots saved to /tmp/issue_9_*.png")
print("PASS")
sys.exit(0)

View file

@ -25,8 +25,7 @@ def recursive_callback(target, prop, value):
callback_count += 1
if callback_count >= MAX_CALLBACKS:
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault")
sys.exit(0)
return
# Chain another animation - this used to cause segfault due to iterator invalidation
target.animate("x", 100 + (callback_count * 20), 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
@ -34,13 +33,29 @@ def recursive_callback(target, prop, value):
# Start the chain
frame.animate("x", 200, 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
def timeout_check(timer, runtime):
"""Safety timeout"""
if callback_count >= MAX_CALLBACKS:
print(f"PASS - {callback_count} callbacks completed")
sys.exit(0)
else:
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed")
sys.exit(1)
# In headless mode mcrfpy.step() is the only clock: drive the animation chain forward.
# Each link is 0.1s; MAX_CALLBACKS links need >= 1.0s of simulated time. Step generously
# and bail out as soon as the chain is complete.
MAX_STEPS = 400
steps = 0
while callback_count < MAX_CALLBACKS and steps < MAX_STEPS:
mcrfpy.step(0.02)
steps += 1
safety_timer = mcrfpy.Timer("safety", timeout_check, 5000, once=True)
if callback_count < MAX_CALLBACKS:
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed after {steps} steps")
sys.exit(1)
# The chain completed without segfault (iterator invalidation in AnimationManager::update).
# The final animation of the chain targets x = 100 + (MAX_CALLBACKS-1)*20; run a few more
# steps so it settles, proving the manager is still healthy after the recursive churn.
for _ in range(20):
mcrfpy.step(0.02)
expected_x = 100 + (MAX_CALLBACKS - 1) * 20
if abs(frame.x - expected_x) > 0.5:
print(f"FAIL - final animation did not complete: frame.x={frame.x}, expected {expected_x}")
sys.exit(1)
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault; frame.x={frame.x}")
sys.exit(0)

View file

@ -41,7 +41,18 @@ SANITIZER_PATTERNS = [
]
# Test directories to run (in order)
TEST_DIRS = ['unit', 'integration', 'regression']
TEST_DIRS = ['unit', 'integration', 'regression', 'demo']
# #372: tests/demo/ was never run by anything, so the demo screens -- which CLAUDE.md
# points at as the canonical API-usage examples -- silently bitrotted until they could
# not even import. Smoke-gate the entry point that exercises screens/base.py and every
# wired DemoScreen, so "read the demos for correct usage" stays true.
#
# An allowlist, not a glob: the other scripts under tests/demo/ are standalone
# showcases that are not part of the demo_main path (see the follow-up issue for
# adopting them). Demos render, so they need a longer timeout than a unit test.
DEMO_SMOKE_TESTS = ['demo_main.py']
DEMO_TIMEOUT = 60 # seconds
# ANSI colors
GREEN = '\033[92m'
@ -113,6 +124,13 @@ def run_test(test_path, verbose=False, timeout=DEFAULT_TIMEOUT,
passed = result.returncode == 0
output = result.stdout + result.stderr
# An uncaught exception is never a pass. Exit code alone was not enough:
# a script that raised during setup registered no timers, was auto-exited 0
# by the engine, and was scored PASS -- 59 tests sat rotted this way, their
# assertions never once executing. (#341/#350/#372)
if 'Traceback (most recent call last)' in output:
passed = False
# Check for PASS/FAIL in output
if 'FAIL' in output and 'PASS' not in output.split('FAIL')[-1]:
passed = False
@ -150,6 +168,9 @@ def find_tests(directory):
test_dir = TESTS_DIR / directory
if not test_dir.exists():
return []
if directory == 'demo':
# #372: allowlisted entry points only -- see DEMO_SMOKE_TESTS.
return [test_dir / name for name in DEMO_SMOKE_TESTS if (test_dir / name).exists()]
return sorted(test_dir.glob("*.py"))
def main():
@ -203,8 +224,11 @@ def main():
for test_path in tests:
test_name = test_path.name
# Demos render several scenes; they need more headroom than a unit test.
test_timeout = (DEMO_TIMEOUT * timeout_multiplier
if test_dir == 'demo' else effective_timeout)
passed, duration, output, san_errors = run_test(
test_path, verbose, effective_timeout,
test_path, verbose, test_timeout,
sanitizer_mode=sanitizer_mode,
valgrind_mode=valgrind_mode
)

View file

@ -24,6 +24,15 @@ const keyboard: Keyboard
const mouse: Mouse
const window: Window
=== MODULE DYNAMIC ATTRIBUTES (7) ===
attr animations (ro) :: Tuple of all currently running Animation objects (tuple, read-only).
attr current_scene (rw) :: The active scene (Scene). Assign a Scene object, or a scene name as a string, to switch scenes.
attr default_transition (rw) :: Default transition (Transition) applied when switching scenes without an explicit transition.
attr default_transition_duration (rw) :: Default scene-transition duration in seconds (float). Must be non-negative.
attr save_dir (ro) :: Directory used for persistent save data (str, read-only). '/save' under Emscripten.
attr scenes (ro) :: Tuple of all registered Scene objects (tuple, read-only).
attr timers (ro) :: Tuple of all active Timer objects (tuple, read-only).
=== SUBMODULES (1) ===
submodule automation

View file

@ -1,12 +1,25 @@
#!/usr/bin/env python3
"""Example of CORRECT test pattern using mcrfpy.step() for automation
Refactored from timer-based approach to synchronous step() pattern.
Note (#350/#341): step() is the simulation clock and never renders;
automation.screenshot() is what forces a render of the current scene.
"""
import mcrfpy
from mcrfpy import automation
from datetime import datetime
import os
import sys
failures = []
def check(condition, message):
if condition:
print(f" ok: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
# This code runs during --exec script execution
print("=== Setting Up Test Scene ===")
@ -32,11 +45,11 @@ caption.font_size = 24
frame.children.append(caption)
# Add click handler to demonstrate interaction
click_received = False
def frame_clicked(x, y, button):
global click_received
click_received = True
print(f"Frame clicked at ({x}, {y}) with button {button}")
# (#230) on_click receives (pos: Vector, button: MouseButton, action: InputState)
clicks_received = []
def frame_clicked(pos, button, action):
clicks_received.append((pos, button, action))
print(f"Frame clicked at ({pos.x}, {pos.y}) with button {button} ({action})")
frame.on_click = frame_clicked
@ -54,13 +67,25 @@ filename = f"WORKING_screenshot_{timestamp}.png"
# Take screenshot - this should now show our red frame
result = automation.screenshot(filename)
print(f"Screenshot taken: {filename} - Result: {result}")
check(result is True, "screenshot() reported success")
check(os.path.exists(filename), f"{filename} exists on disk")
# A blank 1024x768 PNG compresses to a few hundred bytes; real content is far bigger
check(os.path.getsize(filename) > 2000,
f"{filename} contains rendered content (size {os.path.getsize(filename)} > 2000)")
# Test clicking on the frame
automation.click(200, 200) # Click in center of red frame
automation.click((200, 200)) # Click in center of red frame
# Step to process the click
mcrfpy.step(0.1)
check(len(clicks_received) > 0, "frame.on_click fired for click inside the frame")
if clicks_received:
pos, button, action = clicks_received[0]
check((pos.x, pos.y) == (200.0, 200.0), "click reported the position it was sent to")
check(button == mcrfpy.MouseButton.LEFT, "click reported the LEFT button")
check(action == mcrfpy.InputState.PRESSED, "click reported the PRESSED state")
# Test keyboard input
automation.typewrite("Hello from step-based test!")
@ -69,14 +94,26 @@ mcrfpy.step(0.1)
# Take another screenshot to show any changes
filename2 = f"WORKING_screenshot_after_click_{timestamp}.png"
automation.screenshot(filename2)
result2 = automation.screenshot(filename2)
print(f"Second screenshot: {filename2}")
check(result2 is True, "second screenshot() reported success")
check(os.path.exists(filename2), f"{filename2} exists on disk")
# Clean up the artifacts this test produced
for f in (filename, filename2):
if os.path.exists(f):
os.remove(f)
print("Test completed successfully!")
print("\nThis works because:")
print("1. mcrfpy.step() advances simulation synchronously")
print("2. The scene renders during step() calls")
print("2. automation.screenshot() forces a render of the active scene")
print("3. The RenderTexture contains actual rendered content")
print("PASS")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -90,12 +90,21 @@ for _ in range(10):
check("replacement anim completes", anim5b.is_complete == True)
# --- Test 6: Animation object created with loop=True via constructor ---
anim6 = mcrfpy.Animation("x", 100.0, 1.0, loop=True)
check("Animation constructor loop=True", anim6.is_looping == True)
# --- Test 6: loop flag set at Animation creation time ---
# mcrfpy.Animation is no longer exported; the successor factory is target.animate(),
# which returns the Animation object. Same intent: the loop keyword is honored at
# creation, and defaults to False.
sprite6 = mcrfpy.Sprite(pos=(0, 0))
scene.children.append(sprite6)
anim7 = mcrfpy.Animation("x", 100.0, 1.0)
check("Animation constructor default loop=False", anim7.is_looping == False)
anim6 = sprite6.animate("x", 100.0, 1.0, loop=True)
check("animate(loop=True) sets is_looping", anim6.is_looping == True)
anim7 = sprite6.animate("y", 100.0, 1.0)
check("animate() default loop=False", anim7.is_looping == False)
anim8 = sprite6.animate("z_index", 5, 1.0, loop=False)
check("animate(loop=False) sets is_looping False", anim8.is_looping == False)
# --- Summary ---

View file

@ -1,34 +1,65 @@
#!/usr/bin/env python3
"""Test for mcrfpy.createScene() method"""
"""Test for mcrfpy.Scene() creation and activation (formerly mcrfpy.createScene())"""
import mcrfpy
import sys
def test_createScene():
"""Test creating a new scene"""
failures = []
scenes = {}
# Test creating scenes
test_scenes = ["test_scene1", "test_scene2", "special_chars_!@#"]
for scene_name in test_scenes:
try:
_scene = mcrfpy.Scene(scene_name)
print(f" Created scene: {scene_name}")
scenes[scene_name] = mcrfpy.Scene(scene_name)
print(f"PASS: Created scene: {scene_name}")
except Exception as e:
print(f"✗ Failed to create scene {scene_name}: {e}")
return
# Try to set scene to verify it was created
try:
test_scene1.activate() # Note: ensure scene was created
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == "test_scene1":
print("✓ Scene switching works correctly")
else:
print(f"✗ Scene switch failed: expected 'test_scene1', got '{current}'")
except Exception as e:
print(f"✗ Scene switching error: {e}")
print("PASS")
print(f"FAIL: Failed to create scene {scene_name}: {e}")
failures.append(f"create {scene_name}")
continue
if scenes[scene_name].name != scene_name:
print(f"FAIL: scene.name mismatch: expected '{scene_name}', got '{scenes[scene_name].name}'")
failures.append(f"name {scene_name}")
# Activate a created scene to verify it exists and is switchable
if "test_scene1" in scenes:
try:
scenes["test_scene1"].activate()
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == "test_scene1":
print("PASS: Scene switching works correctly")
else:
print(f"FAIL: Scene switch failed: expected 'test_scene1', got '{current}'")
failures.append("activate()")
except Exception as e:
print(f"FAIL: Scene switching error: {e}")
failures.append("activate()")
# current_scene is also writable directly
try:
mcrfpy.current_scene = scenes["test_scene2"]
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == "test_scene2":
print("PASS: mcrfpy.current_scene assignment works correctly")
else:
print(f"FAIL: current_scene assignment failed: expected 'test_scene2', got '{current}'")
failures.append("current_scene=")
except Exception as e:
print(f"FAIL: current_scene assignment error: {e}")
failures.append("current_scene=")
return failures
# Run test immediately
print("Running createScene test...")
test_createScene()
print("Test completed.")
failures = test_createScene()
print("Test completed.")
if failures:
print("FAIL: " + ", ".join(failures))
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,44 +1,78 @@
#!/usr/bin/env python3
"""Test for mcrfpy.setScene() and currentScene() methods"""
"""Test for mcrfpy.current_scene (successor to setScene()/currentScene())"""
import mcrfpy
import sys
print("Starting setScene/currentScene test...")
print("Starting current_scene test...")
# Create test scenes first
scenes = ["scene_A", "scene_B", "scene_C"]
scene_objs = {}
for scene in scenes:
_scene = mcrfpy.Scene(scene)
scene_objs[scene] = mcrfpy.Scene(scene)
print(f"Created scene: {scene}")
results = []
# Test switching between scenes
# Test switching between scenes by name (mcrfpy.current_scene is read/write)
for scene in scenes:
try:
mcrfpy.current_scene = scene
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == scene:
results.append(f"✓ setScene/currentScene works for '{scene}'")
results.append(f"[ok] current_scene set/get by name works for '{scene}'")
else:
results.append(f" Scene mismatch: set '{scene}', got '{current}'")
results.append(f"[FAIL] Scene mismatch: set '{scene}', got '{current}'")
except Exception as e:
results.append(f" Error with scene '{scene}': {e}")
results.append(f"[FAIL] Error with scene '{scene}': {e}")
# Test invalid scene - it should not change the current scene
# Test switching by Scene object, and via Scene.activate()
for scene in scenes:
try:
mcrfpy.current_scene = scene_objs[scene]
current = mcrfpy.current_scene
if current.name == scene and current.active:
results.append(f"[ok] current_scene set by Scene object works for '{scene}'")
else:
results.append(f"[FAIL] Object assign mismatch: set '{scene}', got '{current.name}'")
except Exception as e:
results.append(f"[FAIL] Error assigning Scene object '{scene}': {e}")
try:
scene_objs["scene_B"].activate()
if mcrfpy.current_scene.name == "scene_B":
results.append("[ok] Scene.activate() switches the current scene")
else:
results.append(f"[FAIL] activate() left current scene at '{mcrfpy.current_scene.name}'")
except Exception as e:
results.append(f"[FAIL] Error in Scene.activate(): {e}")
# Test invalid scene - it must not change the current scene.
# Current contract: assigning an unknown scene name raises KeyError and the
# current scene is left untouched (the old setScene() silently ignored it).
current_before = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
nonexistent_scene.activate() # Note: ensure scene was created
try:
mcrfpy.current_scene = "nonexistent_scene"
results.append("[FAIL] Assigning a nonexistent scene name did not raise")
except KeyError:
results.append("[ok] Assigning a nonexistent scene name raises KeyError")
except Exception as e:
results.append(f"[FAIL] Expected KeyError for nonexistent scene, got {type(e).__name__}: {e}")
current_after = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current_before == current_after:
results.append(f"✓ setScene correctly ignores nonexistent scene (stayed on '{current_after}')")
results.append(f"[ok] Failed switch leaves current scene alone (stayed on '{current_after}')")
else:
results.append(f"✗ Scene changed unexpectedly from '{current_before}' to '{current_after}'")
results.append(f"[FAIL] Scene changed unexpectedly from '{current_before}' to '{current_after}'")
# Print results
for result in results:
print(result)
# Determine pass/fail
if all("" in r for r in results):
if all("[ok]" in r for r in results):
print("PASS")
sys.exit(0)
else:
print("FAIL")
print("FAIL")
sys.exit(1)

View file

@ -270,7 +270,16 @@ def build_snapshot():
out.append("# Regenerate intentionally: MCRF_UPDATE_API_SNAPSHOT=1")
out.append("# Singleton/constant VALUES are intentionally NOT captured.")
names = [n for n in dir(mcrfpy) if not n.startswith("_")]
# #356: the dynamic module attributes are getset descriptors on type(mcrfpy).
# Capture them from the DESCRIPTOR, not from getattr(): their value is runtime
# state (current_scene is None here, a Scene in a running game), so snapshotting
# type(value) would make the golden depend on engine state.
dynamic_attrs = sorted(
n for n, d in vars(type(mcrfpy)).items()
if not n.startswith("_") and isinstance(d, types.GetSetDescriptorType)
)
names = [n for n in dir(mcrfpy) if not n.startswith("_") and n not in dynamic_attrs]
enums, classes, funcs, singletons, submodules = [], [], [], [], []
for n in names:
v = getattr(mcrfpy, n)
@ -298,6 +307,16 @@ def build_snapshot():
for n, tn in sorted(singletons):
out.append("const %s: %s" % (n, tn))
out.append("")
out.append("=== MODULE DYNAMIC ATTRIBUTES (%d) ===" % len(dynamic_attrs))
for n in dynamic_attrs:
descr = getattr(type(mcrfpy), n)
doc = descr.__doc__ or ""
# A getset_descriptor is always a data descriptor (it exposes __set__ even
# with a NULL setter), so read-only-ness can only be read off the docstring.
rw = "ro" if "read-only" in doc.lower() else "rw"
out.append("attr %s (%s) :: %s" % (n, rw, first_doc_line(doc) or "<no-doc>"))
out.append("")
out.append("=== SUBMODULES (%d) ===" % len(submodules))
for n in sorted(submodules):

View file

@ -1,80 +1,125 @@
#!/usr/bin/env python3
"""Debug empty paths issue"""
"""Debug empty paths issue
Original intent: pathfinding was returning EMPTY paths on a fully-walkable grid.
This test verifies that A* and Dijkstra both return non-empty paths, that walls
are respected (detour), that an unreachable/blocked destination yields no path,
and that the TCOD map stays in sync with walkability changes.
API notes (2026-07): compute_astar_path/compute_dijkstra/get_dijkstra_path are gone.
Pathfinding lives on GridData: find_path() -> AStarPath | None,
get_dijkstra_map(root=...) -> DijkstraMap (cached; clear_dijkstra_maps() after
walkability changes). TCOD sync is automatic -- there is no sync_tcod_map().
"""
import mcrfpy
import sys
print("Debugging empty paths...")
failures = []
def check(cond, msg):
if cond:
print(f" OK: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
# Create scene and grid
debug = mcrfpy.Scene("debug")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(320, 320))
data = grid.grid_data
# Initialize grid - all walkable
print("\nInitializing grid...")
for y in range(10):
for x in range(10):
grid.at(x, y).walkable = True
data.at(x, y).walkable = True
# Test simple path
print("\nTest 1: Simple path from (0,0) to (5,5)")
path = grid.compute_astar_path(0, 0, 5, 5)
print(f" A* path: {path}")
print(f" Path length: {len(path)}")
path = data.find_path((0, 0), (5, 5))
check(path is not None, "A* found a path on an all-walkable grid")
steps = list(path) if path else []
print(f" A* path: {steps}")
print(f" Path length: {len(steps)}")
check(len(steps) > 0, "A* path is not empty")
check(path is not None and tuple(path.destination) == (5, 5), "A* path ends at (5,5)")
# Test with Dijkstra
print("\nTest 2: Same path with Dijkstra")
grid.compute_dijkstra(0, 0)
dpath = grid.get_dijkstra_path(5, 5)
print(f" Dijkstra path: {dpath}")
print(f" Path length: {len(dpath)}")
dmap = data.get_dijkstra_map(root=(0, 0))
dpath = dmap.path_from((5, 5))
dsteps = list(dpath) if dpath else []
print(f" Dijkstra path: {dsteps}")
print(f" Path length: {len(dsteps)}")
check(len(dsteps) > 0, "Dijkstra path is not empty")
check(dmap.distance((5, 5)) > 0, "Dijkstra distance to (5,5) is finite and positive")
# Check if grid is properly initialized
print("\nTest 3: Checking grid cells")
for y in range(3):
for x in range(3):
cell = grid.at(x, y)
cell = data.at(x, y)
print(f" Cell ({x},{y}): walkable={cell.walkable}")
check(cell.walkable, f"cell ({x},{y}) reports walkable=True")
# Test with walls
print("\nTest 4: Path with wall")
grid.at(2, 2).walkable = False
grid.at(3, 2).walkable = False
grid.at(4, 2).walkable = False
for wx in (2, 3, 4):
data.at(wx, 2).walkable = False
data.clear_dijkstra_maps() # walkability changed: invalidate cached maps
print(" Added wall at y=2, x=2,3,4")
path2 = grid.compute_astar_path(0, 0, 5, 5)
print(f" A* path with wall: {path2}")
print(f" Path length: {len(path2)}")
path2 = data.find_path((0, 0), (5, 5))
check(path2 is not None, "A* still finds a path around the wall")
steps2 = list(path2) if path2 else []
print(f" A* path with wall: {steps2}")
print(f" Path length: {len(steps2)}")
check(len(steps2) > 0, "walled A* path is not empty")
blocked = {(2, 2), (3, 2), (4, 2)}
check(not any(tuple(p) in blocked for p in steps2),
"A* path does not cross the wall cells (TCOD map synced with walkability)")
# Test invalid paths
print("\nTest 5: Path to blocked cell")
grid.at(9, 9).walkable = False
path3 = grid.compute_astar_path(0, 0, 9, 9)
data.at(9, 9).walkable = False
data.clear_dijkstra_maps()
path3 = data.find_path((0, 0), (9, 9))
print(f" Path to blocked cell: {path3}")
check(path3 is None or len(list(path3)) == 0,
"no path is produced to a non-walkable destination")
# Check TCOD map sync
print("\nTest 6: Verify TCOD map is synced")
# Try to force a sync
print(" Checking if syncTCODMap exists...")
if hasattr(grid, 'sync_tcod_map'):
print(" Calling sync_tcod_map()")
grid.sync_tcod_map()
else:
print(" No sync_tcod_map method found")
# Fully wall off (9,8) and (8,9) too -- (9,9) neighbors -- then re-open (9,9):
# a stale TCOD map would still refuse the destination.
data.at(9, 9).walkable = True
data.clear_dijkstra_maps()
path_reopened = data.find_path((0, 0), (9, 9))
check(path_reopened is not None and len(list(path_reopened)) > 0,
"re-opening a blocked cell makes it reachable again (automatic TCOD sync)")
# Try path again
print("\nTest 7: Path after potential sync")
path4 = grid.compute_astar_path(0, 0, 5, 5)
print(f" A* path: {path4}")
print("\nTest 7: Path after mutation round-trip")
path4 = data.find_path((0, 0), (5, 5))
steps4 = list(path4) if path4 else []
print(f" A* path: {steps4}")
check(len(steps4) > 0, "A* path (0,0)->(5,5) still non-empty after mutations")
def timer_cb(timer, runtime):
sys.exit(0)
# Quick UI setup
# Quick UI setup: the grid must survive being attached to a live scene.
ui = debug.children
ui.append(grid)
debug.activate()
exit_timer = mcrfpy.Timer("exit", timer_cb, 100, once=True)
mcrfpy.step(0.1)
check(mcrfpy.current_scene is debug, "grid attached to the active scene")
print("\nStarting timer...")
if failures:
print(f"\nFAIL ({len(failures)} check(s) failed):")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -1,2 +1,34 @@
# This script is intentionally empty
pass
# This script is intentionally (almost) empty.
#
# Intent: the engine must survive a script that sets nothing up -- no scene, no UI,
# no timers -- and must not crash when the headless clock is driven in that state.
#
# Under the #350 exit contract a bare `pass` is no longer a valid --exec script:
# falling off the end exits nonzero with a diagnostic. So the "do nothing" case is
# now spelled as "do nothing, then exit 0", plus the checks that make it a real test.
import sys
import mcrfpy
failures = []
# A script that never creates a scene must still find the engine in a sane state.
if not hasattr(mcrfpy, "step"):
failures.append("mcrfpy.step is missing")
if not hasattr(mcrfpy, "current_scene"):
failures.append("mcrfpy.current_scene is missing")
# Driving the clock with no scene / no timers / no animations must be a harmless no-op.
try:
for _ in range(3):
mcrfpy.step(0.05)
except Exception as e:
failures.append("mcrfpy.step() raised with no scene set up: %r" % (e,))
if failures:
for f in failures:
print("FAIL:", f)
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,7 +1,78 @@
#!/usr/bin/env python3
"""Test if calling mcrfpy.exit() prevents the >>> prompt"""
import mcrfpy
"""Test that mcrfpy.exit() shuts the engine down instead of leaving it running
Original intent: "does calling mcrfpy.exit() prevent the >>> prompt?" -- i.e. does
exit() actually terminate the application rather than falling through into an
interactive/never-ending loop?
Current contract (#350): mcrfpy.exit() calls GameEngine::quit(); it stops the run
loop. It is NOT a SystemExit -- the Python script keeps executing after it, and a
headless --exec script still states its own outcome with sys.exit(). So the real
"did exit() work?" question is answered in a child process that DOES enter the run
loop (--run-forever): with exit() it must terminate; without it, it must not.
"""
import mcrfpy
import os
import subprocess
import sys
failures = []
scratch = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "build")
scratch = os.path.abspath(scratch)
# --- 1. exit() is a no-arg call returning None; it does not abort the interpreter ---
print("Calling mcrfpy.exit() immediately...")
mcrfpy.exit()
print("This should not print if exit worked")
result = mcrfpy.exit()
print("This still prints: exit() quits the engine, it does not raise SystemExit")
if result is not None:
failures.append("mcrfpy.exit() should return None, got %r" % (result,))
try:
mcrfpy.exit(1)
except TypeError:
pass
else:
failures.append("mcrfpy.exit() takes no arguments; exit(1) should raise TypeError")
# --- 2. exit() really stops the run loop (the ">>> prompt"/hang the test was about) ---
child_exit = os.path.join(scratch, "_exit_immediately_child_exit.py")
child_stay = os.path.join(scratch, "_exit_immediately_child_stay.py")
with open(child_exit, "w") as f:
f.write("import mcrfpy\nmcrfpy.exit()\n")
with open(child_stay, "w") as f:
f.write("import mcrfpy\n")
try:
# sys.executable is the mcrogueface binary itself.
proc = subprocess.run(
[sys.executable, "--headless", "--run-forever", "--exec", child_exit],
cwd=scratch, capture_output=True, text=True, timeout=20)
if proc.returncode != 0:
failures.append("exit() child should terminate cleanly, got returncode %d"
% proc.returncode)
except subprocess.TimeoutExpired:
failures.append("exit() did not stop the run loop: --run-forever child hung")
# Control: without exit(), --run-forever must keep the process alive. This proves the
# check above is actually observing exit(), not just a run loop that always ends.
try:
subprocess.run(
[sys.executable, "--headless", "--run-forever", "--exec", child_stay],
cwd=scratch, capture_output=True, text=True, timeout=5)
failures.append("control child (no exit()) terminated on its own; "
"the exit() check above proves nothing")
except subprocess.TimeoutExpired:
pass # expected: still running
for path in (child_exit, child_stay):
if os.path.exists(path):
os.remove(path)
if failures:
for f in failures:
print("FAIL: %s" % f)
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -3,17 +3,26 @@
import mcrfpy
from mcrfpy import automation
import os
import sys
def capture_sprites(timer, runtime):
"""Capture sprite examples after render loop starts"""
SCREENSHOT = "ui_sprite_example.png"
# Take screenshot
automation.screenshot("mcrogueface.github.io/images/ui_sprite_example.png")
results = []
def check(label, condition):
results.append((label, bool(condition)))
print(f"{'PASS' if condition else 'FAIL'}: {label}")
def capture_sprites(timer, runtime):
"""Capture sprite examples once the timer fires"""
# Take screenshot (rendering is on-demand in headless: screenshot forces a render)
automation.screenshot(SCREENSHOT)
print("Sprite screenshot saved!")
# Exit after capturing
sys.exit(0)
check("screenshot file created", os.path.exists(SCREENSHOT))
check("screenshot is non-empty", os.path.exists(SCREENSHOT) and os.path.getsize(SCREENSHOT) > 0)
# Create scene
sprites = mcrfpy.Scene("sprites")
@ -21,9 +30,8 @@ sprites = mcrfpy.Scene("sprites")
# Load texture
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
# Title
title = mcrfpy.Caption(pos=(400, 30), text="Sprite Examples")
title.font = mcrfpy.default_font
# Title (Caption.font is read-only: pass it to the constructor)
title = mcrfpy.Caption(pos=(400, 30), font=mcrfpy.default_font, text="Sprite Examples")
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 255)
@ -33,102 +41,71 @@ frame.fill_color = mcrfpy.Color(64, 64, 128)
frame.outline = 2
# Player sprite
player_label = mcrfpy.Caption(pos=(100, 120), text="Player")
player_label.font = mcrfpy.default_font
player_label = mcrfpy.Caption(pos=(100, 120), font=mcrfpy.default_font, text="Player")
player_label.fill_color = mcrfpy.Color(255, 255, 255)
player = mcrfpy.Sprite(120, 150)
player.texture = texture
player.sprite_index = 84 # Player sprite
player.scale = (3.0, 3.0)
player = mcrfpy.Sprite(pos=(120, 150), texture=texture, sprite_index=84) # Player sprite
player.scale = 3.0
# Enemy sprites
enemy_label = mcrfpy.Caption(pos=(250, 120), text="Enemies")
enemy_label.font = mcrfpy.default_font
enemy_label = mcrfpy.Caption(pos=(250, 120), font=mcrfpy.default_font, text="Enemies")
enemy_label.fill_color = mcrfpy.Color(255, 255, 255)
rat = mcrfpy.Sprite(250, 150)
rat.texture = texture
rat.sprite_index = 123 # Rat
rat.scale = (3.0, 3.0)
rat = mcrfpy.Sprite(pos=(250, 150), texture=texture, sprite_index=123) # Rat
rat.scale = 3.0
big_rat = mcrfpy.Sprite(320, 150)
big_rat.texture = texture
big_rat.sprite_index = 130 # Big rat
big_rat.scale = (3.0, 3.0)
big_rat = mcrfpy.Sprite(pos=(320, 150), texture=texture, sprite_index=130) # Big rat
big_rat.scale = 3.0
cyclops = mcrfpy.Sprite(390, 150)
cyclops.texture = texture
cyclops.sprite_index = 109 # Cyclops
cyclops.scale = (3.0, 3.0)
cyclops = mcrfpy.Sprite(pos=(390, 150), texture=texture, sprite_index=109) # Cyclops
cyclops.scale = 3.0
# Items row
items_label = mcrfpy.Caption(pos=(100, 250), text="Items")
items_label.font = mcrfpy.default_font
items_label = mcrfpy.Caption(pos=(100, 250), font=mcrfpy.default_font, text="Items")
items_label.fill_color = mcrfpy.Color(255, 255, 255)
# Boulder
boulder = mcrfpy.Sprite(100, 280)
boulder.texture = texture
boulder.sprite_index = 66 # Boulder
boulder.scale = (3.0, 3.0)
boulder = mcrfpy.Sprite(pos=(100, 280), texture=texture, sprite_index=66) # Boulder
boulder.scale = 3.0
# Chest
chest = mcrfpy.Sprite(170, 280)
chest.texture = texture
chest.sprite_index = 89 # Closed chest
chest.scale = (3.0, 3.0)
chest = mcrfpy.Sprite(pos=(170, 280), texture=texture, sprite_index=89) # Closed chest
chest.scale = 3.0
# Key
key = mcrfpy.Sprite(240, 280)
key.texture = texture
key.sprite_index = 384 # Key
key.scale = (3.0, 3.0)
key = mcrfpy.Sprite(pos=(240, 280), texture=texture, sprite_index=384) # Key
key.scale = 3.0
# Button
button = mcrfpy.Sprite(310, 280)
button.texture = texture
button.sprite_index = 250 # Button
button.scale = (3.0, 3.0)
button = mcrfpy.Sprite(pos=(310, 280), texture=texture, sprite_index=250) # Button
button.scale = 3.0
# UI elements row
ui_label = mcrfpy.Caption(pos=(100, 380), text="UI Elements")
ui_label.font = mcrfpy.default_font
ui_label = mcrfpy.Caption(pos=(100, 380), font=mcrfpy.default_font, text="UI Elements")
ui_label.fill_color = mcrfpy.Color(255, 255, 255)
# Hearts
heart_full = mcrfpy.Sprite(100, 410)
heart_full.texture = texture
heart_full.sprite_index = 210 # Full heart
heart_full.scale = (3.0, 3.0)
heart_full = mcrfpy.Sprite(pos=(100, 410), texture=texture, sprite_index=210) # Full heart
heart_full.scale = 3.0
heart_half = mcrfpy.Sprite(170, 410)
heart_half.texture = texture
heart_half.sprite_index = 209 # Half heart
heart_half.scale = (3.0, 3.0)
heart_half = mcrfpy.Sprite(pos=(170, 410), texture=texture, sprite_index=209) # Half heart
heart_half.scale = 3.0
heart_empty = mcrfpy.Sprite(240, 410)
heart_empty.texture = texture
heart_empty.sprite_index = 208 # Empty heart
heart_empty.scale = (3.0, 3.0)
heart_empty = mcrfpy.Sprite(pos=(240, 410), texture=texture, sprite_index=208) # Empty heart
heart_empty.scale = 3.0
# Armor
armor = mcrfpy.Sprite(340, 410)
armor.texture = texture
armor.sprite_index = 211 # Armor
armor.scale = (3.0, 3.0)
armor = mcrfpy.Sprite(pos=(340, 410), texture=texture, sprite_index=211) # Armor
armor.scale = 3.0
# Scale demonstration
scale_label = mcrfpy.Caption(pos=(500, 120), text="Scale Demo")
scale_label.font = mcrfpy.default_font
scale_label = mcrfpy.Caption(pos=(500, 120), font=mcrfpy.default_font, text="Scale Demo")
scale_label.fill_color = mcrfpy.Color(255, 255, 255)
# Same sprite at different scales
for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]):
s = mcrfpy.Sprite(500 + i * 60, 150)
s.texture = texture
s.sprite_index = 84 # Player
s.scale = (scale, scale)
s = mcrfpy.Sprite(pos=(500 + i * 60, 150), texture=texture, sprite_index=84) # Player
s.scale = scale
sprites.children.append(s)
# Add all elements to scene
@ -156,5 +133,22 @@ ui.append(scale_label)
# Switch to scene
sprites.activate()
# Set timer to capture after rendering starts
capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True)
# Everything got built and attached
check("scene populated with sprite showcase", len(sprites.children) == 23)
check("sprite scale applied", player.scale == 3.0)
check("sprite index applied", player.sprite_index == 84)
# Set timer to capture; headless has no clock of its own, so drive it with step()
capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True)
for _ in range(4):
mcrfpy.step(0.05)
check("capture timer fired", any(label.startswith("screenshot") for label, _ in results))
failures = [label for label, ok in results if not ok]
if failures:
print(f"FAIL - {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -28,6 +28,7 @@ for i in range(1, 8):
# Apply camera rotation
grid.camera_rotation = 30.0 # 30 degree rotation
grid.center_camera((4, 4)) # Center on middle of grid
assert grid.camera_rotation == 30.0, "camera_rotation should round-trip"
ui.append(grid)
@ -46,6 +47,10 @@ for i in range(1, 8):
grid2.camera_rotation = 0.0 # No rotation
grid2.center_camera((4, 4))
assert grid2.camera_rotation == 0.0, "camera_rotation=0 should round-trip"
# camera_rotation is per-view state: rotating grid must not disturb grid2
assert grid.camera_rotation == 30.0, "camera_rotation must be per-grid, not shared"
ui.append(grid2)
@ -69,6 +74,9 @@ for i in range(6):
grid3.rotation = 15.0
grid3.origin = (100, 75) # Center origin for rotation
grid3.center_camera((3, 3))
assert grid3.rotation == 15.0, "viewport rotation should round-trip"
# viewport rotation is distinct from camera rotation
assert grid3.camera_rotation == 0.0, "setting .rotation must not set .camera_rotation"
ui.append(grid3)
@ -76,9 +84,9 @@ label3 = mcrfpy.Caption(text="Grid with viewport rotation=15 (rotates entire wid
ui.append(label3)
# Test center_camera computes correct pixel center
# (GridView has no .cell_size accessor; the raw cell metrics aren't needed here --
# center_camera is verified by the relationships between the centers it produces.)
test_grid = mcrfpy.Grid(grid_size=(20, 15), pos=(0, 0), size=(320, 240))
cell_w = test_grid.cell_size[0]
cell_h = test_grid.cell_size[1]
# center_camera((0, 0)) should put tile (0,0) at view center
test_grid.center_camera((0, 0))
@ -90,6 +98,8 @@ c0 = test_grid.center
test_grid.center_camera((10, 7))
c1 = test_grid.center
assert c0.x != c1.x or c0.y != c1.y, "center_camera at different positions should give different centers"
# ...and it should move monotonically with the tile coordinate
assert c1.x > c0.x and c1.y > c0.y, "center_camera to a larger tile should increase the center"
# center_camera at same position twice should be idempotent
test_grid.center_camera((5, 5))

View file

@ -73,24 +73,31 @@ def test_gridview_repr():
print("PASS: GridView repr")
def test_gridview_grid_property():
"""GridView.grid returns the correct Grid with identity preservation."""
"""GridView.grid_data returns the shared GridData with identity preservation.
Contract change (#313/#361): a view no longer exposes `.grid` (the source view);
Grid and GridView are the same type, and the map they share is `.grid_data`.
"""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(15, 10), texture=tex, pos=(0, 0), size=(240, 160))
view = mcrfpy.GridView(grid=grid, pos=(250, 0), size=(240, 160))
assert view.grid is grid, "view.grid should be the same Grid object"
assert view.grid.grid_w == 15
assert view.grid_data is grid.grid_data, "view must share the source Grid's GridData"
assert view.grid_data.grid_w == 15
# Identity preserved on repeated access
assert view.grid is view.grid
print("PASS: GridView.grid property with identity")
assert view.grid_data is view.grid_data
print("PASS: GridView.grid_data property with identity")
def test_gridview_no_grid():
"""GridView without a grid doesn't crash."""
def test_gridview_default_grid():
"""GridView with no source grid doesn't crash; it gets its own default GridData."""
view = mcrfpy.GridView()
assert view.grid is None
gd = view.grid_data
assert gd is not None, "a bare GridView still owns a default GridData"
assert gd.grid_w > 0 and gd.grid_h > 0
assert gd is view.grid_data # identity preserved
r = repr(view)
assert "None" in r
print("PASS: GridView without grid")
assert "GridView" in r
print("PASS: GridView without a source grid")
if __name__ == "__main__":
test_gridview_creation()
@ -99,6 +106,6 @@ if __name__ == "__main__":
test_gridview_multi_view()
test_gridview_repr()
test_gridview_grid_property()
test_gridview_no_grid()
test_gridview_default_grid()
print("All GridView tests passed")
sys.exit(0)

View file

@ -1,4 +1,6 @@
import mcrfpy
import sys
scene = mcrfpy.Scene("test")
scene.activate()
f1 = mcrfpy.Frame((10,10), (100,100), fill_color = (255, 0, 0, 64))
@ -8,7 +10,37 @@ f_child = mcrfpy.Frame((25,25), (50,50), fill_color = (0, 0, 255, 64))
scene.children.append(f1)
scene.children.append(f2)
f1.children.append(f_child)
failures = []
# Before reparent: child belongs to f1
if len(f1.children) != 1:
failures.append("f1 should have 1 child before reparent, has %d" % len(f1.children))
if len(f2.children) != 0:
failures.append("f2 should have 0 children before reparent, has %d" % len(f2.children))
if f_child.parent is not f1:
failures.append("f_child.parent should be f1 before reparent, got %r" % (f_child.parent,))
# Reparent by assigning .parent
f_child.parent = f2
print(f1.children)
print(f2.children)
# After reparent: child moved out of f1 and into f2 (no duplication, no orphaning)
if len(f1.children) != 0:
failures.append("f1 should have 0 children after reparent, has %d" % len(f1.children))
if len(f2.children) != 1:
failures.append("f2 should have 1 child after reparent, has %d" % len(f2.children))
elif f2.children[0] is not f_child:
failures.append("f2's child is not f_child")
if f_child.parent is not f2:
failures.append("f_child.parent should be f2 after reparent, got %r" % (f_child.parent,))
if failures:
for f in failures:
print("FAIL: " + f)
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -188,10 +188,15 @@ check(f"standard easing complete() goes to target ({sprite9.x:.1f})",
abs(sprite9.x - 500.0) < 5.0)
# --- Test 11: Animation constructor with ping-pong easing ---
anim10 = mcrfpy.Animation("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True)
check("Animation constructor with PING_PONG", anim10 is not None)
check("Animation constructor loop=True", anim10.is_looping == True)
# --- Test 11: Animation construction with ping-pong easing ---
# mcrfpy.Animation is no longer exported; animations are constructed via the
# target's .animate() method, which returns the Animation object.
sprite10 = mcrfpy.Sprite(pos=(0, 0))
scene.children.append(sprite10)
anim10 = sprite10.animate("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True)
check("animate() with PING_PONG", anim10 is not None)
check("animate() loop=True", anim10.is_looping == True)
check("animate() ping-pong easing retained", anim10.is_complete == False)
# --- Summary ---

View file

@ -3,43 +3,80 @@
import mcrfpy
from mcrfpy import automation
import os
import sys
import time
failures = []
fired = []
OUT_DIR = "test_screenshots"
GOOD_PATH = os.path.join(OUT_DIR, "test_screenshot.png")
BAD_PATH = os.path.join("no_such_dir_xyz", "test_screenshot.png")
def take_screenshot(timer, runtime):
"""Take screenshot after render starts"""
"""Take screenshot after the timer fires"""
print(f"Timer callback fired at runtime: {runtime}")
fired.append(runtime)
# Try different paths
paths = [
"test_screenshot.png",
"./test_screenshot.png",
"mcrogueface.github.io/images/test_screenshot.png"
]
# A writable path must succeed and produce a real PNG file
print(f"Trying to save to: {GOOD_PATH}")
if automation.screenshot(GOOD_PATH) is not True:
failures.append(f"screenshot({GOOD_PATH}) did not return True")
return
if not os.path.exists(GOOD_PATH):
failures.append(f"screenshot reported success but {GOOD_PATH} does not exist")
return
size = os.path.getsize(GOOD_PATH)
if size == 0:
failures.append(f"{GOOD_PATH} is empty")
return
with open(GOOD_PATH, "rb") as f:
magic = f.read(8)
if magic != b"\x89PNG\r\n\x1a\n":
failures.append(f"{GOOD_PATH} is not a PNG (magic={magic!r})")
return
print(f"Success: {GOOD_PATH} ({size} bytes)")
for path in paths:
try:
print(f"Trying to save to: {path}")
automation.screenshot(path)
print(f"Success: {path}")
except Exception as e:
print(f"Failed {path}: {e}")
# An unwritable path must fail cleanly (return False, no exception)
print(f"Trying to save to: {BAD_PATH}")
if automation.screenshot(BAD_PATH) is not False:
failures.append(f"screenshot({BAD_PATH}) should have returned False")
return
print(f"Failed as expected: {BAD_PATH}")
sys.exit(0)
# Create minimal scene
test = mcrfpy.Scene("test")
# Add a visible element
caption = mcrfpy.Caption(pos=(100, 100), text="Screenshot Test")
caption.font = mcrfpy.default_font
# Add a visible element (Caption.font is read-only as of #320 -- set it in the ctor)
caption = mcrfpy.Caption(pos=(100, 100), font=mcrfpy.default_font, text="Screenshot Test")
caption.fill_color = mcrfpy.Color(255, 255, 255)
caption.font_size = 24
test.children.append(caption)
test.activate()
# Use timer to ensure rendering has started
os.makedirs(OUT_DIR, exist_ok=True)
if os.path.exists(GOOD_PATH):
os.remove(GOOD_PATH)
# Timer fires at 500ms; headless has no clock of its own, so step() drives it (#350)
print("Setting timer...")
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True) # Wait 0.5 seconds
print("Timer set, entering game loop...")
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True)
print("Timer set, advancing the clock...")
for _ in range(20): # 20 * 50ms = 1000ms > 500ms
mcrfpy.step(0.05)
if fired:
break
if not fired:
failures.append("timer never fired after 1000ms of stepping")
if failures:
for f in failures:
print(f"FAIL: {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -9,12 +9,14 @@ print("=" * 30)
# Global state to track callback
callback_count = 0
callback_args = []
# #229 - Animation callbacks now receive (target, property, value) instead of (anim, target)
def my_callback(target, prop, value):
"""Simple callback that prints when animation completes"""
global callback_count
callback_count += 1
callback_args.append((target, prop, value))
print(f"Animation completed! Callback #{callback_count}")
print(f" Target: {type(target).__name__}, Property: {prop}, Value: {value}")
@ -23,14 +25,15 @@ callback_demo = mcrfpy.Scene("callback_demo")
callback_demo.activate()
# Create a frame to animate
frame = mcrfpy.Frame((100, 100), (200, 200), fill_color=(255, 0, 0))
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200), fill_color=(255, 0, 0))
ui = callback_demo.children
ui.append(frame)
# Test 1: Animation with callback
# mcrfpy.Animation is no longer exported; animations are constructed via the
# target drawable's .animate() method, which starts them immediately.
print("Starting animation with callback (1.0s duration)...")
anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback)
anim.start(frame)
frame.animate("x", 400.0, 1.0, mcrfpy.Easing.EASE_IN_OUT_QUAD, callback=my_callback)
# Use mcrfpy.step() to advance past animation completion
mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s
@ -38,12 +41,21 @@ mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s
if callback_count != 1:
print(f"FAIL: Expected 1 callback, got {callback_count}")
sys.exit(1)
# The callback receives (target, property, final_value) -- and the animation must
# have actually driven the property to its target.
cb_target, cb_prop, cb_value = callback_args[0]
if cb_target is not frame or cb_prop != "x" or cb_value != 400.0:
print(f"FAIL: Bad callback args: {callback_args[0]}")
sys.exit(1)
if frame.x != 400.0:
print(f"FAIL: Expected frame.x == 400.0, got {frame.x}")
sys.exit(1)
print("SUCCESS: Callback fired exactly once!")
# Test 2: Animation without callback
print("\nTesting animation without callback (0.5s duration)...")
anim2 = mcrfpy.Animation("y", 300.0, 0.5, "linear")
anim2.start(frame)
frame.animate("y", 300.0, 0.5, mcrfpy.Easing.LINEAR)
# Advance past second animation
mcrfpy.step(0.7)
@ -51,7 +63,11 @@ mcrfpy.step(0.7)
if callback_count != 1:
print(f"FAIL: Callback count changed to {callback_count}")
sys.exit(1)
if frame.y != 300.0:
print(f"FAIL: Expected frame.y == 300.0, got {frame.y}")
sys.exit(1)
print("SUCCESS: No unexpected callbacks fired!")
print("\nAnimation callback feature working correctly!")
print("PASS")
sys.exit(0)

View file

@ -3,15 +3,30 @@
Test Animation Chaining
=======================
Demonstrates proper animation chaining to avoid glitches.
Demonstrates proper animation chaining to avoid glitches: an entity walks a path
one tile at a time, and each step's animation only starts after the previous
step's animation has completed (never two overlapping position animations).
Headless: mcrfpy.step(dt) is the clock -- it drives both timers and animations.
Animations are created with target.animate(...) (mcrfpy.Animation is no longer
constructible); chaining uses the completion callback instead of polling.
"""
import mcrfpy
import sys
failures = []
def check(name, condition, detail=""):
if condition:
print(f" PASS: {name}")
else:
print(f" FAIL: {name} {detail}")
failures.append(name)
class PathAnimator:
"""Handles step-by-step path animation with proper chaining"""
def __init__(self, entity, path, step_duration=0.3, on_complete=None):
self.entity = entity
self.path = path
@ -19,63 +34,67 @@ class PathAnimator:
self.step_duration = step_duration
self.on_complete = on_complete
self.animating = False
self.check_timer_name = f"path_check_{id(self)}"
self.completed_steps = [] # (index, draw_pos) recorded as each step lands
self.overlaps = 0 # times a new step started while one was in flight
self.anim_x = None
self.anim_y = None
def start(self):
"""Start animating along the path"""
if not self.path or self.animating:
return
self.current_index = 0
self.animating = True
self._animate_next_step()
def _animate_next_step(self):
"""Animate to the next position in the path"""
if self.current_index >= len(self.path):
# Path complete
self.animating = False
if hasattr(self, '_check_timer'):
self._check_timer.stop()
if self.on_complete:
self.on_complete()
return
# Detect a chaining violation: the previous step must be finished
if self.anim_x is not None and not self.anim_x.is_complete:
self.overlaps += 1
if self.anim_y is not None and not self.anim_y.is_complete:
self.overlaps += 1
# Get target position
target_x, target_y = self.path[self.current_index]
# Create animations
self.anim_x = mcrfpy.Animation("x", float(target_x), self.step_duration, "easeInOut")
self.anim_y = mcrfpy.Animation("y", float(target_y), self.step_duration, "easeInOut")
# Start animations
self.anim_x.start(self.entity)
self.anim_y.start(self.entity)
# Create + start animations ('x'/'y' animate the entity's draw position,
# in tile coordinates). The x animation's callback chains the next step.
self.anim_y = self.entity.animate("y", float(target_y), self.step_duration,
mcrfpy.Easing.EASE_IN_OUT)
self.anim_x = self.entity.animate("x", float(target_x), self.step_duration,
mcrfpy.Easing.EASE_IN_OUT,
callback=self._on_step_complete)
# Update visibility if entity has this method
if hasattr(self.entity, 'update_visibility'):
self.entity.update_visibility()
# Set timer to check completion
self._check_timer = mcrfpy.Timer(self.check_timer_name, self._check_completion, 50)
def _check_completion(self, timer, runtime):
"""Check if current animation is complete"""
if hasattr(self.anim_x, 'is_complete') and self.anim_x.is_complete:
# Move to next step
self.current_index += 1
timer.stop()
self._animate_next_step()
def _on_step_complete(self, target, prop, value):
"""Animation completion callback -- advance to the next path node"""
self.completed_steps.append((self.current_index,
(self.entity.draw_pos.x, self.entity.draw_pos.y)))
self.current_index += 1
self._animate_next_step()
# Create test scene
chain_test = mcrfpy.Scene("chain_test")
# Create grid
grid = mcrfpy.Grid(grid_w=20, grid_h=15)
grid = mcrfpy.Grid(grid_size=(20, 15), pos=(100, 100), size=(600, 450))
grid.fill_color = mcrfpy.Color(20, 20, 30)
# Add a color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add a color layer for cell coloring (GridPoint has no .color any more)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
# Simple map
for y in range(15):
@ -84,33 +103,29 @@ for y in range(15):
if x == 0 or x == 19 or y == 0 or y == 14:
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, mcrfpy.Color(60, 40, 40))
color_layer.set((x, y), mcrfpy.Color(60, 40, 40))
else:
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
# Create entities
player = mcrfpy.Entity((2, 2), grid=grid)
player = mcrfpy.Entity(grid_pos=(2, 2))
player.sprite_index = 64 # @
grid.entities.append(player)
enemy = mcrfpy.Entity((17, 12), grid=grid)
enemy = mcrfpy.Entity(grid_pos=(17, 12))
enemy.sprite_index = 69 # E
grid.entities.append(enemy)
# UI setup
ui = chain_test.children
ui.append(grid)
grid.pos = (100, 100)
grid.size = (600, 450)
title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
status = mcrfpy.Caption(pos=(100, 50), text="Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
info = mcrfpy.Caption(pos=(100, 70), text="Status: Ready")
info.fill_color = mcrfpy.Color(100, 255, 100)
ui.append(info)
@ -118,42 +133,48 @@ ui.append(info)
# Path animators
player_animator = None
enemy_animator = None
player_done = False
enemy_done = False
PLAYER_PATH = [
(2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right
(6, 3), (6, 4), (6, 5), (6, 6), # Down
(7, 6), (8, 6), (9, 6), (10, 6), # Right
(10, 7), (10, 8), (10, 9), # Down
]
ENEMY_PATH = [
(17, 12), (16, 12), (15, 12), (14, 12), # Left
(14, 11), (14, 10), (14, 9), # Up
(13, 9), (12, 9), (11, 9), (10, 9), # Left
(10, 8), (10, 7), (10, 6), # Up
]
def animate_player():
"""Animate player along a path"""
global player_animator
# Define path
path = [
(2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right
(6, 3), (6, 4), (6, 5), (6, 6), # Down
(7, 6), (8, 6), (9, 6), (10, 6), # Right
(10, 7), (10, 8), (10, 9), # Down
]
def on_complete():
global player_done
player_done = True
info.text = "Player animation complete!"
player_animator = PathAnimator(player, path, step_duration=0.2, on_complete=on_complete)
player_animator = PathAnimator(player, PLAYER_PATH, step_duration=0.2,
on_complete=on_complete)
player_animator.start()
info.text = "Animating player..."
def animate_enemy():
"""Animate enemy along a path"""
global enemy_animator
# Define path
path = [
(17, 12), (16, 12), (15, 12), (14, 12), # Left
(14, 11), (14, 10), (14, 9), # Up
(13, 9), (12, 9), (11, 9), (10, 9), # Left
(10, 8), (10, 7), (10, 6), # Up
]
def on_complete():
global enemy_done
enemy_done = True
info.text = "Enemy animation complete!"
enemy_animator = PathAnimator(enemy, path, step_duration=0.25, on_complete=on_complete)
enemy_animator = PathAnimator(enemy, ENEMY_PATH, step_duration=0.25,
on_complete=on_complete)
enemy_animator.start()
info.text = "Animating enemy..."
@ -164,60 +185,101 @@ def animate_both():
animate_enemy()
# Camera follow test
camera_follow = False
camera_follow = True
camera_updates = 0
def update_camera(timer, runtime):
"""Update camera to follow player if enabled"""
global camera_updates
if camera_follow and player_animator and player_animator.animating:
# Smooth camera follow
center_x = player.x * 30 # Assuming ~30 pixels per cell
center_y = player.y * 30
cam_anim = mcrfpy.Animation("center", (center_x, center_y), 0.25, "linear")
cam_anim.start(grid)
# Input handler
def handle_input(key, state):
global camera_follow
if state != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.Q:
sys.exit(0)
elif key == mcrfpy.Key.NUM_1:
animate_player()
elif key == mcrfpy.Key.NUM_2:
animate_enemy()
elif key == mcrfpy.Key.NUM_3:
animate_both()
elif key == mcrfpy.Key.C:
camera_follow = not camera_follow
info.text = f"Camera follow: {'ON' if camera_follow else 'OFF'}"
elif key == mcrfpy.Key.R:
# Reset positions
player.x, player.y = 2, 2
enemy.x, enemy.y = 17, 12
info.text = "Positions reset"
# Smooth camera follow. grid.center is in pixels; entity.x/.y are the
# entity's draw position in pixels (draw_pos is the same in tile coords).
grid.animate("center", (player.x, player.y), 0.25, mcrfpy.Easing.LINEAR)
camera_updates += 1
# Setup
chain_test.activate()
chain_test.on_key = handle_input
# Camera update timer
cam_update_timer = mcrfpy.Timer("cam_update", update_camera, 100)
print("Animation Chaining Test")
print("=======================")
print("This test demonstrates proper animation chaining")
print("to avoid simultaneous position updates.")
print()
print("Controls:")
print(" 1 - Animate player step by step")
print(" 2 - Animate enemy step by step")
print(" 3 - Animate both (simultaneous)")
print(" C - Toggle camera follow")
print(" R - Reset positions")
print(" Q - Quit")
print()
print("Notice how each entity moves one tile at a time,")
print("waiting for each step to complete before the next.")
# --- Drive the chained animations headlessly -------------------------------
animate_both()
check("player animator started", player_animator.animating)
check("enemy animator started", enemy_animator.animating)
# Path node 0 is the entity's starting cell, so the first 0.2s step is a no-op
# move. Halfway through the *second* step the entity must be strictly between
# nodes 0 and 1 -- i.e. the animation is interpolating, one step at a time.
for _ in range(2):
mcrfpy.step(0.1) # completes step 0, chains step 1
check("first step completed before the next began",
player_animator.current_index == 1 and len(player_animator.completed_steps) == 1,
f"(index={player_animator.current_index})")
mcrfpy.step(0.1) # halfway through step 1: (2,2) -> (3,2)
mid_x = player.draw_pos.x
check("player interpolates between tiles", 2.0 < mid_x < 3.0,
f"(draw_x={mid_x})")
check("only one step in flight at a time (mid-step)",
player_animator.current_index == 1,
f"(index={player_animator.current_index})")
# Player: 16 nodes * 0.2s; enemy: 14 nodes * 0.25s -> 3.5s worst case.
elapsed = 0.3
while elapsed < 6.0 and not (player_done and enemy_done):
mcrfpy.step(0.05)
elapsed += 0.05
# --- Assertions ------------------------------------------------------------
check("player path completed", player_done)
check("enemy path completed", enemy_done)
check("player animator stopped", not player_animator.animating)
check("enemy animator stopped", not enemy_animator.animating)
check("no overlapping player animations", player_animator.overlaps == 0,
f"(overlaps={player_animator.overlaps})")
check("no overlapping enemy animations", enemy_animator.overlaps == 0,
f"(overlaps={enemy_animator.overlaps})")
check("player visited every path node",
[i for i, _ in player_animator.completed_steps] == list(range(len(PLAYER_PATH))),
f"({player_animator.completed_steps})")
check("enemy visited every path node",
[i for i, _ in enemy_animator.completed_steps] == list(range(len(ENEMY_PATH))),
f"({enemy_animator.completed_steps})")
# Each step landed exactly on its path node (chaining kept positions in sync)
player_landings_ok = all(pos == (float(px), float(py))
for (i, pos), (px, py)
in zip(player_animator.completed_steps, PLAYER_PATH))
check("each player step landed on its path node", player_landings_ok,
f"({player_animator.completed_steps})")
enemy_landings_ok = all(pos == (float(px), float(py))
for (i, pos), (px, py)
in zip(enemy_animator.completed_steps, ENEMY_PATH))
check("each enemy step landed on its path node", enemy_landings_ok,
f"({enemy_animator.completed_steps})")
check("player ended at final path node",
(player.draw_pos.x, player.draw_pos.y) == (float(PLAYER_PATH[-1][0]),
float(PLAYER_PATH[-1][1])),
f"({player.draw_pos})")
check("enemy ended at final path node",
(enemy.draw_pos.x, enemy.draw_pos.y) == (float(ENEMY_PATH[-1][0]),
float(ENEMY_PATH[-1][1])),
f"({enemy.draw_pos})")
# Camera-follow timer ran while the player was animating
check("camera follow timer fired during animation", camera_updates > 0,
f"(updates={camera_updates})")
if failures:
print(f"FAIL: {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -33,9 +33,8 @@ for i in range(10):
ui.append(f)
initial_frames.append(f)
# Animate them
anim = mcrfpy.Animation("y", 300.0, 2.0, "easeOutBounce")
anim.start(f)
# Animate them (mcrfpy.Animation is gone; animations are built by the target)
f.animate("y", 300.0, 2.0, mcrfpy.Easing.EASE_OUT_BOUNCE)
print(f"Initial scene has {len(ui)} elements")
@ -55,6 +54,7 @@ print(f"Scene has {len(ui)} elements after clearing")
# Create new animated objects
print("Creating new animated objects...")
new_frames = []
for i in range(5):
f = mcrfpy.Frame(pos=(100 + i*50, 200), size=(40, 40))
f.fill_color = mcrfpy.Color(100 + i*30, 50, 200)
@ -62,20 +62,34 @@ for i in range(5):
# Start animation on the new frame
target_x = 300 + i * 50
anim = mcrfpy.Animation("x", float(target_x), 1.0, "easeInOut")
anim.start(f)
f.animate("x", float(target_x), 1.0, mcrfpy.Easing.EASE_IN_OUT)
new_frames.append((f, float(target_x)))
print("New objects created and animated")
print(f"Scene now has {len(ui)} elements")
# Let new animations run
mcrfpy.step(1.5)
# Let new animations run to completion (duration 1.0s)
for _ in range(20):
mcrfpy.step(0.1)
# Final check
failures = []
# Final check: element count survived the clear/recreate cycle
print(f"\nFinal scene has {len(ui)} elements")
if len(ui) == 7: # 2 captions + 5 new frames
print("SUCCESS: Animation removal test passed!")
sys.exit(0)
else:
print(f"FAIL: Expected 7 elements, got {len(ui)}")
if len(ui) != 7: # 2 captions + 5 new frames
failures.append(f"Expected 7 elements, got {len(ui)}")
# The removed frames' animations must not have kept running (or crashed);
# the new frames' animations must have actually completed.
for i, (f, target_x) in enumerate(new_frames):
if abs(f.x - target_x) > 0.5:
failures.append(f"new frame {i}: x={f.x}, expected {target_x}")
if failures:
for msg in failures:
print(f"FAIL: {msg}")
sys.exit(1)
print("SUCCESS: Animation removal test passed!")
print("PASS")
sys.exit(0)

View file

@ -1,164 +1,164 @@
#!/usr/bin/env python3
"""Test that API documentation generator works correctly."""
"""Test that API documentation generator works correctly.
The hand-written docs/API_REFERENCE.md is gone; the canonical API reference is now
docs/API_REFERENCE_DYNAMIC.md, generated from the compiled module by
tools/generate_dynamic_docs.py (MCRF_* docstring macros -> introspection -> markdown).
This test verifies that generated artifact exists, is well-formed, and stays in sync
with the live mcrfpy module.
"""
import os
import sys
from pathlib import Path
# tests/unit/test_api_docs.py -> repo root -> docs/
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DOCS_PATH = REPO_ROOT / "docs" / "API_REFERENCE_DYNAMIC.md"
def test_api_docs_exist():
"""Test that API documentation was generated."""
docs_path = Path("docs/API_REFERENCE.md")
if not docs_path.exists():
print("ERROR: API documentation not found at docs/API_REFERENCE.md")
if not DOCS_PATH.exists():
print(f"ERROR: API documentation not found at {DOCS_PATH}")
return False
print(" API documentation file exists")
print("+ API documentation file exists")
# Check file size
size = docs_path.stat().st_size
size = DOCS_PATH.stat().st_size
if size < 1000:
print(f"ERROR: API documentation seems too small ({size} bytes)")
return False
print(f" API documentation has reasonable size ({size} bytes)")
print(f"+ API documentation has reasonable size ({size} bytes)")
# Read content
with open(docs_path, 'r') as f:
with open(DOCS_PATH, 'r') as f:
content = f.read()
# Check for expected sections
# Check for expected sections (current generated layout)
expected_sections = [
"# McRogueFace API Reference",
"## Overview",
"## Classes",
"## Table of Contents",
"## Module Attributes",
"## Functions",
"## Automation Module"
"## Classes",
]
missing = []
for section in expected_sections:
if section not in content:
missing.append(section)
missing = [s for s in expected_sections if s not in content]
if missing:
print(f"ERROR: Missing sections: {missing}")
return False
print(" All expected sections present")
# Check for key classes
print("+ All expected sections present")
# Check for key classes ("### ClassName" headings)
key_classes = ["Frame", "Caption", "Sprite", "Grid", "Entity", "Scene"]
missing_classes = []
for cls in key_classes:
if f"### class {cls}" not in content:
missing_classes.append(cls)
missing_classes = [c for c in key_classes if f"\n### {c}\n" not in content]
if missing_classes:
print(f"ERROR: Missing classes: {missing_classes}")
return False
print("✓ All key classes documented")
# Check for key functions
key_functions = ["createScene", "setScene", "currentScene", "find", "setTimer"]
missing_funcs = []
for func in key_functions:
if f"### {func}" not in content:
missing_funcs.append(func)
print("+ All key classes documented")
# Check for key module-level functions ("### `name(...)`" headings)
key_functions = ["find", "find_all", "step", "get_metrics", "set_scale", "exit"]
missing_funcs = [f for f in key_functions if f"\n### `{f}(" not in content]
if missing_funcs:
print(f"ERROR: Missing functions: {missing_funcs}")
return False
print("✓ All key functions documented")
# Check automation module
if "automation.screenshot" in content:
print("✓ Automation module documented")
else:
print("ERROR: Automation module not properly documented")
print("+ All key functions documented")
# Scene management moved from functions to the mcrfpy.current_scene attribute
if "### `mcrfpy.current_scene`" not in content:
print("ERROR: mcrfpy.current_scene not documented under Module Attributes")
return False
print("+ Module attributes documented")
# Count documentation entries
class_count = content.count("### class ")
func_count = content.count("### ") - class_count - content.count("### automation.")
auto_count = content.count("### automation.")
class_count = sum(1 for line in content.splitlines()
if line.startswith("### ") and not line.startswith("### `"))
func_count = sum(1 for line in content.splitlines() if line.startswith("### `"))
member_count = sum(1 for line in content.splitlines() if line.startswith("#### "))
print(f"\nDocumentation Coverage:")
print(f"- Classes: {class_count}")
print(f"- Functions: {func_count}")
print(f"- Automation methods: {auto_count}")
print(f"- Functions/attributes: {func_count}")
print(f"- Class members: {member_count}")
if class_count == 0 or func_count == 0 or member_count == 0:
print("ERROR: documentation contains no entries")
return False
return True
def test_doc_accuracy():
"""Test that documentation matches actual API."""
# Import mcrfpy to check
import mcrfpy
print("\nVerifying documentation accuracy...")
# Read documentation
with open("docs/API_REFERENCE.md", 'r') as f:
with open(DOCS_PATH, 'r') as f:
content = f.read()
passed = True
# Check that all public classes are documented
actual_classes = [name for name in dir(mcrfpy)
actual_classes = [name for name in dir(mcrfpy)
if isinstance(getattr(mcrfpy, name), type) and not name.startswith('_')]
undocumented = []
for cls in actual_classes:
if f"### class {cls}" not in content:
undocumented.append(cls)
undocumented = [c for c in actual_classes if f"\n### {c}\n" not in content]
if undocumented:
print(f"WARNING: Undocumented classes: {undocumented}")
print(f"ERROR: Undocumented classes: {undocumented}")
passed = False
else:
print("✓ All public classes are documented")
print(f"+ All {len(actual_classes)} public classes are documented")
# Check functions
actual_funcs = [name for name in dir(mcrfpy)
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
actual_funcs = [name for name in dir(mcrfpy)
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
and not isinstance(getattr(mcrfpy, name), type)]
undoc_funcs = []
for func in actual_funcs:
if f"### {func}" not in content:
undoc_funcs.append(func)
undoc_funcs = [f for f in actual_funcs if f"\n### `{f}(" not in content]
if undoc_funcs:
print(f"WARNING: Undocumented functions: {undoc_funcs}")
print(f"ERROR: Undocumented functions: {undoc_funcs}")
passed = False
else:
print("✓ All public functions are documented")
return True
print(f"+ All {len(actual_funcs)} public functions are documented")
return passed
def main():
"""Run all API documentation tests."""
print("API Documentation Tests")
print("======================\n")
all_passed = True
# Test 1: Documentation exists and is complete
print("Test 1: Documentation Generation")
if not test_api_docs_exist():
all_passed = False
print()
# Test 2: Documentation accuracy
print("Test 2: Documentation Accuracy")
if not test_doc_accuracy():
all_passed = False
print()
if all_passed:
print(" All API documentation tests passed!")
print("PASS: All API documentation tests passed!")
sys.exit(0)
else:
print(" Some tests failed.")
print("FAIL: Some tests failed.")
sys.exit(1)
if __name__ == '__main__':
main()
main()

View file

@ -3,7 +3,15 @@
Test A* Pathfinding Implementation
==================================
Compares A* with Dijkstra and the existing find_path method.
Compares A* (GridData.find_path) with Dijkstra (GridData.get_dijkstra_map)
and verifies path validity around obstacles.
API notes (current contract):
- The old grid.compute_astar_path/compute_dijkstra/get_dijkstra_path methods are gone.
A* is now GridData.find_path(start, end, ...) -> AStarPath | None
Dijkstra is now GridData.get_dijkstra_map(root=...) -> DijkstraMap (.path_from/.distance)
- Pathfinding lives on GridData, not on the Grid view (mcrfpy.Grid is a GridView).
- Headless has no automatic clock: timers only fire from mcrfpy.step().
"""
import mcrfpy
@ -13,24 +21,42 @@ import time
print("A* Pathfinding Test")
print("==================")
failures = []
def check(cond, msg):
if cond:
print(f" PASS: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
# Create scene and grid
astar_test = mcrfpy.Scene("astar_test")
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(50, 50), size=(400, 400))
data = grid.grid_data # pathfinding lives on GridData
# Initialize grid - all walkable
for y in range(20):
for x in range(20):
grid.at(x, y).walkable = True
data.at(x, y).walkable = True
# Create a wall barrier with a narrow passage
# Create a wall barrier with a narrow passage.
# The barrier is 4 cells thick (x 8..11) and spans the full grid height, with a
# 1-cell-tall corridor carved through it at y == 10. Two corrections vs. the
# original test: (a) a partial-height wall could simply be rounded diagonally for
# the same cost, and (b) a single free cell inside a 4-thick wall is not a passage
# at all -- every neighbour is wall, so it can never be entered.
print("\nCreating wall with narrow passage...")
for y in range(5, 15):
walls = set()
for y in range(20):
for x in range(8, 12):
if not (x == 10 and y == 10): # Leave a gap at (10, 10)
grid.at(x, y).walkable = False
print(f" Wall at ({x}, {y})")
if y == 10: # corridor through the barrier, including (10, 10)
continue
data.at(x, y).walkable = False
walls.add((x, y))
print(f"\nPassage at (10, 10)")
print(f"Wall cells: {len(walls)}, passage at (10, 10)")
data.clear_dijkstra_maps()
# Test points
start = (2, 10)
@ -38,93 +64,125 @@ end = (18, 10)
print(f"\nFinding path from {start} to {end}")
# Test 1: A* pathfinding
print("\n1. Testing A* pathfinding (compute_astar_path):")
start_time = time.time()
astar_path = grid.compute_astar_path(start[0], start[1], end[0], end[1])
astar_time = time.time() - start_time
print(f" A* path length: {len(astar_path)}")
print(f" A* time: {astar_time*1000:.3f} ms")
if astar_path:
print(f" First 5 steps: {astar_path[:5]}")
# Test 2: find_path method (which should also use A*)
print("\n2. Testing find_path method:")
def as_cells(path):
return [(int(v.x), int(v.y)) for v in path]
def is_contiguous(cells):
for a, b in zip(cells, cells[1:]):
if max(abs(a[0] - b[0]), abs(a[1] - b[1])) != 1:
return False
return True
# Test 1: A* pathfinding
print("\n1. Testing A* pathfinding (find_path):")
start_time = time.time()
find_path_result = grid.find_path(start[0], start[1], end[0], end[1])
find_path_time = time.time() - start_time
print(f" find_path length: {len(find_path_result)}")
print(f" find_path time: {find_path_time*1000:.3f} ms")
if find_path_result:
print(f" First 5 steps: {find_path_result[:5]}")
astar_path = data.find_path(start, end)
astar_time = time.time() - start_time
check(astar_path is not None, "A* found a path through the barrier")
# NOTE: iterating an AStarPath consumes it, so read .remaining before as_cells().
astar_remaining = astar_path.remaining if astar_path else 0
astar_cells = as_cells(astar_path) if astar_path else []
print(f" A* path length: {len(astar_cells)}")
print(f" A* time: {astar_time*1000:.3f} ms")
print(f" First 5 steps: {astar_cells[:5]}")
check(bool(astar_cells) and astar_cells[-1] == end, "A* path terminates at the destination")
check(not (walls & set(astar_cells)), "A* path never enters a wall cell")
check(is_contiguous([start] + astar_cells), "A* path is contiguous from the start cell")
check((10, 10) in astar_cells, "A* path uses the narrow passage at (10, 10)")
# Test 2: A* path costs/endpoints via the AStarPath object
print("\n2. Testing AStarPath object:")
check(as_cells([astar_path.origin])[0] == start, "AStarPath.origin is the start cell")
check(as_cells([astar_path.destination])[0] == end, "AStarPath.destination is the end cell")
check(astar_remaining == len(astar_cells), "AStarPath.remaining matches path length")
check(astar_path.remaining == 0, "AStarPath is consumed once fully walked/iterated")
# Test 3: Dijkstra pathfinding for comparison
print("\n3. Testing Dijkstra pathfinding:")
start_time = time.time()
grid.compute_dijkstra(start[0], start[1])
dijkstra_path = grid.get_dijkstra_path(end[0], end[1])
dmap = data.get_dijkstra_map(root=start)
dijkstra_path = dmap.path_from(end)
dijkstra_time = time.time() - start_time
print(f" Dijkstra path length: {len(dijkstra_path)}")
dijkstra_cells = as_cells(dijkstra_path) if dijkstra_path else []
print(f" Dijkstra path length: {len(dijkstra_cells)}")
print(f" Dijkstra time: {dijkstra_time*1000:.3f} ms")
if dijkstra_path:
print(f" First 5 steps: {dijkstra_path[:5]}")
print(f" First 5 steps: {dijkstra_cells[:5]}")
check(bool(dijkstra_cells), "Dijkstra found a path back to the root")
check(not (walls & set(dijkstra_cells)), "Dijkstra path never enters a wall cell")
check((10, 10) in dijkstra_cells, "Dijkstra path uses the narrow passage at (10, 10)")
check(dmap.distance(end) is not None, "Dijkstra distance to the destination is defined")
# Compare results
# Compare results - both are optimal, so step counts must agree
print("\nComparison:")
print(f" A* vs find_path: {'SAME' if astar_path == find_path_result else 'DIFFERENT'}")
print(f" A* vs Dijkstra: {'SAME' if astar_path == dijkstra_path else 'DIFFERENT'}")
print(f" A* steps: {len(astar_cells)}, Dijkstra steps: {len(dijkstra_cells)}")
check(len(astar_cells) == len(dijkstra_cells),
"A* and Dijkstra agree on optimal step count")
# Test with no path (blocked endpoints)
# Test 4: no path (blocked destination inside the wall)
print("\n4. Testing with blocked destination:")
blocked_end = (10, 8) # Inside the wall
grid.at(blocked_end[0], blocked_end[1]).walkable = False
no_path = grid.compute_astar_path(start[0], start[1], blocked_end[0], blocked_end[1])
print(f" Path to blocked cell: {no_path} (should be empty)")
blocked_end = (10, 8) # inside the wall
check(data.at(*blocked_end).walkable is False, "blocked destination is unwalkable")
no_path = data.find_path(start, blocked_end)
print(f" Path to blocked cell: {no_path}")
check(no_path is None or len(no_path) == 0, "no path is returned for a blocked destination")
# Test diagonal movement
# Test 5: diagonal movement
print("\n5. Testing diagonal paths:")
diag_start = (0, 0)
diag_end = (5, 5)
diag_path = grid.compute_astar_path(diag_start[0], diag_start[1], diag_end[0], diag_end[1])
print(f" Diagonal path from {diag_start} to {diag_end}:")
print(f" Length: {len(diag_path)}")
print(f" Path: {diag_path}")
diag_path = data.find_path(diag_start, diag_end)
diag_cells = as_cells(diag_path) if diag_path else []
print(f" Diagonal path from {diag_start} to {diag_end}: {diag_cells}")
# Optimal diagonal path is 5 moves (one diagonal step per cell)
check(len(diag_cells) == 5, "diagonal path from (0,0) to (5,5) takes 5 steps")
check(diag_cells and diag_cells[-1] == diag_end, "diagonal path reaches the destination")
# Expected optimal diagonal path length is 5 moves (moving diagonally each step)
# Performance test with larger path
# Test 6: performance / corner-to-corner agreement
print("\n6. Performance test (corner to corner):")
corner_paths = []
methods = [
("A*", lambda: grid.compute_astar_path(0, 0, 19, 19)),
("Dijkstra", lambda: (grid.compute_dijkstra(0, 0), grid.get_dijkstra_path(19, 19))[1])
]
results = {}
start_time = time.time()
corner_astar = as_cells(data.find_path((0, 0), (19, 19)) or [])
results["A*"] = (len(corner_astar), time.time() - start_time)
start_time = time.time()
corner_dmap = data.get_dijkstra_map(root=(0, 0))
corner_dijkstra = as_cells(corner_dmap.path_from((19, 19)) or [])
results["Dijkstra"] = (len(corner_dijkstra), time.time() - start_time)
for name, (steps, elapsed) in results.items():
print(f" {name}: {steps} steps in {elapsed*1000:.3f} ms")
check(len(corner_astar) > 0 and len(corner_dijkstra) > 0,
"corner-to-corner path found by both algorithms")
check(len(corner_astar) == len(corner_dijkstra),
"corner-to-corner step counts agree between A* and Dijkstra")
for name, method in methods:
start_time = time.time()
path = method()
elapsed = time.time() - start_time
print(f" {name}: {len(path)} steps in {elapsed*1000:.3f} ms")
# Quick smoke test that the grid renders in a scene and the clock advances.
# Headless: mcrfpy.step() is the only clock; a Timer never fires on its own.
timer_fired = []
print("\nA* pathfinding tests completed!")
print("Summary:")
print(" - A* pathfinding is working correctly")
print(" - Paths match between A* and Dijkstra")
print(" - Empty paths returned for blocked destinations")
print(" - Diagonal movement supported")
# Quick visual test
def visual_test(timer, runtime):
print("\nVisual test timer fired")
sys.exit(0)
timer_fired.append(runtime)
# Set up minimal UI for visual test
ui = astar_test.children
ui.append(grid)
grid.pos = (50, 50)
grid.size = (400, 400)
astar_test.activate()
visual_test_timer = mcrfpy.Timer("visual", visual_test, 100, once=True)
print("\nStarting visual test...")
print("\nStarting visual test...")
for _ in range(10):
mcrfpy.step(0.05)
check(bool(timer_fired), "scene timer fired after stepping the headless clock")
print("\nA* pathfinding tests completed!")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,19 +1,40 @@
#!/usr/bin/env python3
"""Test Python builtins in function context like the failing demos"""
"""Test Python builtins in function context like the failing demos
Regression guard: builtins (range, list.append, f-strings) must keep working at
module level, inside functions, and -- critically -- after mcrfpy objects have
been constructed. A broken embedded-interpreter builtins dict used to make
`range()` blow up in exactly those spots.
Failures are RECORDED, not just printed: the script exits 1 if any check fails.
"""
import mcrfpy
import sys
import traceback
failures = []
def fail(label, msg):
failures.append(f"{label}: {msg}")
print(f" x FAIL {label}: {msg}")
print("Testing builtins in different contexts...")
print("=" * 50)
# Test 1: At module level (working in our test)
# Test 1: At module level
print("Test 1: Module level")
try:
xs = []
for x in range(3):
print(f" x={x}")
print(" ✓ Module level works")
xs.append(x)
if xs != [0, 1, 2]:
fail("module level range", f"expected [0, 1, 2], got {xs}")
else:
print(" ok Module level works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
traceback.print_exc()
fail("module level range", f"{type(e).__name__}: {e}")
print()
@ -21,13 +42,17 @@ print()
print("Test 2: Inside function")
def test_function():
try:
xs = [x for x in range(3)]
xs2 = []
for x in range(3):
print(f" x={x}")
print(" ✓ Function level works")
xs2.append(f"x={x}")
if xs != [0, 1, 2] or xs2 != ["x=0", "x=1", "x=2"]:
fail("function level builtins", f"got {xs}, {xs2}")
return
print(" ok Function level works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("function level builtins", f"{type(e).__name__}: {e}")
test_function()
@ -38,30 +63,35 @@ print("Test 3: Function creating mcrfpy objects")
def create_scene():
try:
test = mcrfpy.Scene("test")
print(" ✓ Created scene")
# Now try range
for x in range(3):
print(f" x={x}")
print(" ✓ Range after createScene works")
# Create grid
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
print(" ✓ Created grid")
# Try range again
for x in range(3):
print(f" x={x}")
print(" ✓ Range after Grid creation works")
print(" ok Created scene")
# Now try range -- must still work after a Scene exists
xs = [x for x in range(3)]
if xs != [0, 1, 2]:
fail("range after Scene()", f"expected [0, 1, 2], got {xs}")
return None
print(" ok Range after Scene creation works")
# Create grid (current API: grid_size tuple)
grid = mcrfpy.Grid(grid_size=(10, 10))
print(" ok Created grid")
# Try range again -- must still work after a Grid exists
xs = [x for x in range(3)]
if xs != [0, 1, 2]:
fail("range after Grid()", f"expected [0, 1, 2], got {xs}")
return None
print(" ok Range after Grid creation works")
return grid
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("mcrfpy object construction context", f"{type(e).__name__}: {e}")
return None
grid = create_scene()
if grid is None:
fail("create_scene", "returned None")
print()
@ -70,19 +100,22 @@ print("Test 4: Exact failing pattern")
def failing_pattern():
try:
failing_test = mcrfpy.Scene("failing_test")
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
# This is where it fails in the demos
grid = mcrfpy.Grid(grid_size=(14, 10))
# This is where it used to fail in the demos
walls = []
print(" About to enter range loop...")
for x in range(1, 8):
walls.append((x, 1))
print(f" ✓ Created walls: {walls}")
expected = [(x, 1) for x in range(1, 8)]
if walls != expected:
fail("demo wall pattern", f"expected {expected}, got {walls}")
return
print(f" ok Created walls: {walls}")
except Exception as e:
print(f" ✗ Error at line: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("demo wall pattern", f"{type(e).__name__}: {e}")
failing_pattern()
@ -95,34 +128,53 @@ def test_append():
walls = []
# Test 1: Simple append
walls.append((1, 1))
print(" ✓ Single append works")
if walls != [(1, 1)]:
fail("single append", f"got {walls}")
return
print(" ok Single append works")
# Test 2: Manual loop
i = 0
while i < 3:
walls.append((i, 1))
i += 1
print(f" ✓ While loop append works: {walls}")
if walls != [(1, 1), (0, 1), (1, 1), (2, 1)]:
fail("while loop append", f"got {walls}")
return
print(f" ok While loop append works: {walls}")
# Test 3: Range with different operations
walls2 = []
for x in range(3):
tup = (x, 2)
walls2.append(tup)
print(f" ✓ Range with temp variable works: {walls2}")
if walls2 != [(0, 2), (1, 2), (2, 2)]:
fail("range with temp variable", f"got {walls2}")
return
print(f" ok Range with temp variable works: {walls2}")
# Test 4: Direct tuple creation in append
walls3 = []
for x in range(3):
walls3.append((x, 3))
print(f" ✓ Direct tuple append works: {walls3}")
if walls3 != [(0, 3), (1, 3), (2, 3)]:
fail("direct tuple append", f"got {walls3}")
return
print(f" ok Direct tuple append works: {walls3}")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("append in loop", f"{type(e).__name__}: {e}")
test_append()
print()
print("All tests complete.")
if failures:
print(f"FAIL ({len(failures)} check(s) failed)")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("All tests complete.")
print("PASS")
sys.exit(0)

View file

@ -1,31 +1,76 @@
#!/usr/bin/env python3
"""Simple test for Color setter fix"""
"""Simple test for Color setter fix
Original intent: assigning a cell color from a plain (r, g, b) tuple must work
just as well as assigning a mcrfpy.Color object.
API update: per-cell color no longer lives on GridPoint (grid.at(x, y).color is
gone). The successor API is a ColorLayer attached to the grid:
cl = mcrfpy.ColorLayer(name="bg"); grid.add_layer(cl)
cl.set((x, y), color)
So the same tuple-vs-Color coercion is exercised through ColorLayer.set.
"""
import mcrfpy
import sys
print("Testing Color fix...")
# Test 1: Create grid
failures = 0
# Test 1: Create grid + color layer
try:
test = mcrfpy.Scene("test")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
print("✓ Grid created")
grid = mcrfpy.Grid(grid_size=(5, 5))
color_layer = mcrfpy.ColorLayer(name="bg")
grid.add_layer(color_layer)
print("+ Grid created")
except Exception as e:
print(f"✗ Grid creation failed: {e}")
exit(1)
print(f"x Grid creation failed: {e}")
sys.exit(1)
# Test 2: Set color with tuple
try:
grid.at(0, 0).color = (100, 100, 100)
print("✓ Tuple color assignment works")
color_layer.set((0, 0), (100, 100, 100))
c = color_layer.at((0, 0))
assert (c.r, c.g, c.b) == (100, 100, 100), f"expected (100,100,100), got {(c.r, c.g, c.b)}"
print("+ Tuple color assignment works")
except Exception as e:
print(f"✗ Tuple assignment failed: {e}")
print(f"x Tuple assignment failed: {e}")
failures += 1
# Test 3: Set color with Color object
try:
grid.at(0, 0).color = mcrfpy.Color(200, 200, 200)
print("✓ Color object assignment works!")
color_layer.set((0, 0), mcrfpy.Color(200, 200, 200))
c = color_layer.at((0, 0))
assert (c.r, c.g, c.b) == (200, 200, 200), f"expected (200,200,200), got {(c.r, c.g, c.b)}"
print("+ Color object assignment works!")
except Exception as e:
print(f"✗ Color assignment failed: {e}")
print(f"x Color assignment failed: {e}")
failures += 1
print("Done.")
# Test 4: RGBA tuple (4-component) also coerces
try:
color_layer.set((1, 1), (10, 20, 30, 40))
c = color_layer.at((1, 1))
assert (c.r, c.g, c.b, c.a) == (10, 20, 30, 40), f"expected (10,20,30,40), got {(c.r, c.g, c.b, c.a)}"
print("+ RGBA tuple assignment works")
except Exception as e:
print(f"x RGBA tuple assignment failed: {e}")
failures += 1
# Test 5: bad input must raise, not silently succeed
try:
color_layer.set((2, 2), "not a color")
print("x Bad color value was accepted")
failures += 1
except (TypeError, ValueError):
print("+ Invalid color rejected")
if failures:
print(f"FAIL ({failures} check(s) failed)")
sys.exit(1)
print("Done.")
print("PASS")
sys.exit(0)

View file

@ -1,7 +1,19 @@
#!/usr/bin/env python3
"""Test if Color assignment is the trigger"""
"""Test if Color assignment is the trigger
Originally a repro script for a crash/corruption suspected in per-cell Color
assignment (grid.at(x, y).color = Color(...)) followed by range() iteration.
API update: GridPoint no longer carries a .color -- per-cell color now lives on a
ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), Color)).
The original intent (bulk color writes must not corrupt the interpreter, and the
colors must actually stick) is preserved against the new API.
"""
import mcrfpy
import sys
failures = []
print("Testing Color operations with range()...")
print("=" * 50)
@ -10,44 +22,61 @@ print("=" * 50)
print("Test 1: Color assignment in grid")
try:
test1 = mcrfpy.Scene("test1")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(25, 15))
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# Assign color to a cell
grid.at(0, 0).color = mcrfpy.Color(200, 200, 220)
colors.set((0, 0), mcrfpy.Color(200, 200, 220))
c = colors.at((0, 0))
assert (c.r, c.g, c.b) == (200, 200, 220), f"color readback wrong: {c}"
print(" ✓ Single color assignment works")
# Test range
for i in range(25):
pass
print(" ✓ range(25) works after single color assignment")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
failures.append(f"Test 1: {type(e).__name__}: {e}")
# Test 2: Multiple color assignments
print("\nTest 2: Multiple color assignments")
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(25, 15))
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# Multiple properties including color
for y in range(15):
for x in range(25):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
colors.set((x, y), mcrfpy.Color(200, 200, 220))
print(" ✓ Completed all property assignments")
# Every cell must have kept its value
for y in range(15):
for x in range(25):
c = colors.at((x, y))
assert (c.r, c.g, c.b) == (200, 200, 220), f"cell ({x},{y}) = {c}"
assert grid.at(x, y).walkable is True, f"cell ({x},{y}) not walkable"
assert grid.at(x, y).transparent is True, f"cell ({x},{y}) not transparent"
print(" ✓ All 375 cells read back correctly")
# This is where it would fail
for i in range(25):
pass
print(" ✓ range(25) still works!")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 2: {type(e).__name__}: {e}")
# Test 3: Exact reproduction of failing pattern
print("\nTest 3: Exact pattern from dijkstra_demo_final.py")
@ -55,37 +84,46 @@ try:
# Recreate the exact function
def create_demo():
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
# Create grid
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(25, 15))
grid.fill_color = mcrfpy.Color(0, 0, 0)
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# Initialize all as floor
for y in range(15):
for x in range(25):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
colors.set((x, y), mcrfpy.Color(200, 200, 220))
# Create an interesting dungeon layout
walls = []
# Room walls
# Top-left room
for x in range(1, 8): walls.append((x, 1))
return grid, walls
grid, walls = create_demo()
assert walls == [(x, 1) for x in range(1, 8)], f"walls corrupted: {walls}"
fc = grid.fill_color
assert (fc.r, fc.g, fc.b) == (0, 0, 0), f"fill_color corrupted: {fc}"
print(f" ✓ Function completed successfully, walls: {walls}")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 3: {type(e).__name__}: {e}")
print("\nConclusion: The bug is inconsistent and may be related to:")
print("- Memory layout at the time of execution")
print("- Specific bytecode patterns in the Python code")
print("- C++ reference counting issues with Color objects")
print("- Stack/heap corruption in the grid.at() implementation")
print()
if failures:
for f in failures:
print(f"FAILED: {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,70 +1,138 @@
#!/usr/bin/env python3
"""Test that confirms the Color setter bug"""
"""Test that the grid cell Color setter does not leave a pending exception.
Original bug: PyObject_to_sfColor in UIGridPoint.cpp called PyArg_ParseTuple to
parse the assigned color. When handed an mcrfpy.Color object (rather than a
tuple) the parse failed, the setter swallowed the failure without clearing the
error indicator, and the *pending* exception then erupted out of the next,
totally unrelated Python operation (e.g. `range(25)`).
GridPoint.color no longer exists; per-cell color now lives on a ColorLayer
(grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)). This test is
retargeted onto that successor API, preserving the original intent:
- a tuple is accepted
- an mcrfpy.Color object is accepted (the case that used to fail)
- neither leaves a stray pending exception that surfaces later
- the assigned color actually round-trips
"""
import mcrfpy
import sys
print("Testing GridPoint color setter bug...")
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
def make_color_layer(w, h):
scene = mcrfpy.Scene(f"colorsetter_{w}x{h}")
grid = mcrfpy.Grid(grid_size=(w, h))
layer = mcrfpy.ColorLayer(name="bg")
grid.add_layer(layer)
return grid, layer
print("Testing grid cell color setter (pending-exception bug)...")
print("=" * 50)
# Test 1: Setting color with tuple (old way)
# Test 1: Setting color with a tuple (the path that always worked)
print("Test 1: Setting color with tuple")
try:
test1 = mcrfpy.Scene("test1")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
# This should work (PyArg_ParseTuple expects tuple)
grid.at(0, 0).color = (200, 200, 220)
# Check if exception is pending
grid, layer = make_color_layer(5, 5)
layer.set((0, 0), (200, 200, 220))
# The original bug surfaced here: an unrelated op raising the pending error.
_ = list(range(1))
print(" ✓ Tuple assignment works")
c = layer.at((0, 0))
check("tuple assignment raises nothing", True)
check(
"tuple color round-trips",
(c.r, c.g, c.b) == (200, 200, 220),
f"got ({c.r}, {c.g}, {c.b})",
)
except Exception as e:
print(f" ✗ Tuple assignment failed: {type(e).__name__}: {e}")
check("tuple assignment", False, f"{type(e).__name__}: {e}")
print()
# Test 2: Setting color with Color object (the bug)
# Test 2: Setting color with a Color object (this is what used to set the
# pending exception). It must now work outright -- no exception, pending or not.
print("Test 2: Setting color with Color object")
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
# This will fail in PyArg_ParseTuple but not report it
grid.at(0, 0).color = mcrfpy.Color(200, 200, 220)
print(" ⚠️ Color assignment appeared to work...")
# But exception is pending!
grid, layer = make_color_layer(5, 5)
layer.set((0, 0), mcrfpy.Color(200, 200, 220))
# If a stale error indicator were left behind, this innocent call would
# raise it instead.
_ = list(range(1))
print(" ✓ No exception detected (unexpected!)")
c = layer.at((0, 0))
check("Color object assignment raises nothing", True)
check(
"Color object round-trips",
(c.r, c.g, c.b) == (200, 200, 220),
f"got ({c.r}, {c.g}, {c.b})",
)
except Exception as e:
print(f" ✗ Exception detected: {type(e).__name__}: {e}")
print(" This confirms the bug - exception was set but not raised")
check("Color object assignment", False, f"{type(e).__name__}: {e}")
print()
# Test 3: Multiple color assignments
# Test 3: Many Color assignments in a row (reproduces the original failure
# shape: the error only became visible on a later, unrelated operation).
print("Test 3: Multiple Color assignments (reproducing original bug)")
try:
test3 = mcrfpy.Scene("test3")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
# Do multiple color assignments
for y in range(2): # Just 2 rows to be quick
grid, layer = make_color_layer(25, 15)
for y in range(2):
for x in range(25):
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
print(" All color assignments completed...")
# This should fail
layer.set((x, y), mcrfpy.Color(200, 200, 220))
# The canary from the original bug report.
for i in range(25):
pass
print(" ✓ range(25) worked (unexpected!)")
c = layer.at((24, 1))
check("50 Color assignments leave no pending exception", True)
check(
"last assigned cell round-trips",
(c.r, c.g, c.b) == (200, 200, 220),
f"got ({c.r}, {c.g}, {c.b})",
)
except Exception as e:
print(f" ✗ range(25) failed as expected: {type(e).__name__}")
print(" The exception was set during color assignment")
check("multiple Color assignments", False, f"{type(e).__name__}: {e}")
print()
print("Bug confirmed: PyObject_to_sfColor in UIGridPoint.cpp")
print("doesn't clear the exception when PyArg_ParseTuple fails.")
print("The fix: Either check PyErr_Occurred() after ParseTuple,")
print("or support mcrfpy.Color objects directly.")
# Test 4: A genuinely bad color must raise a REAL exception, immediately -- the
# other half of the bug was errors being set but never raised.
print("Test 4: Invalid color raises immediately")
try:
grid, layer = make_color_layer(5, 5)
try:
layer.set((0, 0), "not a color")
check("invalid color raises", False, "no exception raised")
except TypeError:
check("invalid color raises TypeError at the call site", True)
# And the failed call must not have left an error indicator behind.
_ = list(range(1))
check("failed set leaves no pending exception", True)
except Exception as e:
check("invalid color handling", False, f"{type(e).__name__}: {e}")
print()
print("=" * 50)
if failures:
print(f"FAILED ({len(failures)}): {', '.join(failures)}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -8,12 +8,23 @@ import sys
import mcrfpy
def make_color_layer(size=(10, 10)):
"""Create a Grid with an attached ColorLayer, returning (grid, layer).
add_layer() takes a layer object and no keyword arguments; the ColorLayer
constructor is what accepts name/z_index.
"""
grid = mcrfpy.Grid(grid_size=size)
layer = mcrfpy.ColorLayer(name='color', z_index=0)
grid.add_layer(layer)
return grid, layer
def test_apply_threshold_basic():
"""apply_threshold sets colors in range"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((0, 0, 0, 0)) # Clear all
# Apply threshold - all cells should get blue
@ -32,8 +43,7 @@ def test_apply_threshold_with_alpha():
"""apply_threshold handles RGBA colors"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.apply_threshold(hmap, (0.0, 1.0), (100, 150, 200, 128))
@ -47,8 +57,7 @@ def test_apply_threshold_preserves_outside():
"""apply_threshold doesn't modify cells outside range"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((255, 0, 0)) # Fill with red
# Apply threshold for range that doesn't include 0.5
@ -65,8 +74,7 @@ def test_apply_threshold_with_color_object():
"""apply_threshold accepts Color objects"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
color = mcrfpy.Color(50, 100, 150)
layer.apply_threshold(hmap, (0.0, 1.0), color)
@ -80,8 +88,7 @@ def test_apply_threshold_size_mismatch():
"""apply_threshold rejects mismatched HeightMap size"""
hmap = mcrfpy.HeightMap((5, 5)) # Different size
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
try:
layer.apply_threshold(hmap, (0.0, 1.0), (255, 0, 0))
@ -97,8 +104,7 @@ def test_apply_gradient_basic():
"""apply_gradient interpolates colors"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
# Apply gradient from black to white
result = layer.apply_gradient(hmap, (0.0, 1.0), (0, 0, 0), (255, 255, 255))
@ -118,8 +124,7 @@ def test_apply_gradient_full_range():
# Test at minimum of range
hmap_low = mcrfpy.HeightMap((10, 10), fill=0.0)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.apply_gradient(hmap_low, (0.0, 1.0), (100, 0, 0), (200, 255, 0))
@ -144,8 +149,7 @@ def test_apply_gradient_preserves_outside():
"""apply_gradient doesn't modify cells outside range"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((255, 0, 0)) # Fill with red
# Apply gradient for range that doesn't include 0.5
@ -161,8 +165,7 @@ def test_apply_ranges_fixed_colors():
"""apply_ranges with fixed colors"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((0, 0, 0))
result = layer.apply_ranges(hmap, [
@ -183,8 +186,7 @@ def test_apply_ranges_gradient():
"""apply_ranges with gradient specification"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
# Gradient from (0,0,0) to (255,255,255) over range [0,1]
# At value 0.5, should be ~(127,127,127)
@ -201,8 +203,7 @@ def test_apply_ranges_mixed():
"""apply_ranges with mixed fixed and gradient entries"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((0, 0, 0))
# Test mixed: gradient that includes 0.5
@ -222,8 +223,7 @@ def test_apply_ranges_later_wins():
"""apply_ranges: later ranges override earlier ones"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.apply_ranges(hmap, [
((0.0, 1.0), (255, 0, 0)), # Red, matches everything
@ -240,8 +240,7 @@ def test_apply_ranges_no_match_unchanged():
"""apply_ranges leaves unmatched cells unchanged"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((128, 128, 128)) # Gray marker
layer.apply_ranges(hmap, [
@ -259,8 +258,7 @@ def test_apply_threshold_invalid_range():
"""apply_threshold rejects min > max"""
hmap = mcrfpy.HeightMap((10, 10))
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
try:
layer.apply_threshold(hmap, (1.0, 0.0), (255, 0, 0))
@ -277,8 +275,7 @@ def test_apply_gradient_narrow_range():
# Use a value exactly at the range
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
# Apply gradient over exact value (min == max)
layer.apply_gradient(hmap, (0.5, 0.5), (0, 0, 0), (255, 255, 255))

View file

@ -8,32 +8,49 @@ Demonstrates:
2. Getting distances to any position
3. Finding paths from any position back to the root
4. Multi-target pathfinding (flee/approach scenarios)
API notes (updated for current mcrfpy):
- The `mcrfpy.libtcod` module is gone. Its successor is the GridData Dijkstra API:
grid.grid_data.get_dijkstra_map(root=(x, y)) -> DijkstraMap
dmap.root / dmap.distance((x, y)) / dmap.path_from((x, y))
grid.grid_data.clear_dijkstra_maps() (invalidate after walkability changes)
- GridPoint has no .tilesprite/.color; tiles go on a TileLayer, colors on a ColorLayer.
"""
import mcrfpy
from mcrfpy import libtcod
import sys
failures = []
def check(cond, message):
"""Record a failed check instead of aborting, so all checks run."""
if not cond:
failures.append(message)
print(f" FAIL: {message}")
return cond
def adjacent(a, b):
"""True if a and b are the same cell or 8-way neighbors."""
return abs(int(a[0]) - int(b[0])) <= 1 and abs(int(a[1]) - int(b[1])) <= 1
def create_test_grid():
"""Create a test grid with obstacles"""
dijkstra_test = mcrfpy.Scene("dijkstra_test")
# Create grid
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
# Create grid (already has a default tile layer)
grid = mcrfpy.Grid(grid_size=(20, 20))
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Store color_layer on grid for access elsewhere
grid._color_layer = color_layer
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
data = grid.grid_data
# Initialize all cells as walkable
for y in range(grid.grid_h):
for x in range(grid.grid_w):
for y in range(data.grid_h):
for x in range(data.grid_w):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.tilesprite = 46 # . period
color_layer.set(x, y, mcrfpy.Color(50, 50, 50))
color_layer.set((x, y), mcrfpy.Color(50, 50, 50))
# Create some walls to make pathfinding interesting
# Vertical wall
@ -41,187 +58,261 @@ def create_test_grid():
cell = grid.at(10, y)
cell.walkable = False
cell.transparent = False
cell.tilesprite = 219 # Block
color_layer.set(10, y, mcrfpy.Color(100, 100, 100))
color_layer.set((10, y), mcrfpy.Color(100, 100, 100))
# Horizontal wall
for x in range(5, 15):
if x != 10: # Leave a gap
if x != 10: # (10, 10) is already part of the vertical wall
cell = grid.at(x, 10)
cell.walkable = False
cell.transparent = False
cell.tilesprite = 219
color_layer.set(x, 10, mcrfpy.Color(100, 100, 100))
color_layer.set((x, 10), mcrfpy.Color(100, 100, 100))
return grid
# Walkability changed after construction; drop any cached maps
data.clear_dijkstra_maps()
return grid, color_layer
def test_basic_dijkstra():
"""Test basic Dijkstra functionality"""
print("\n=== Testing Basic Dijkstra ===")
grid = create_test_grid()
grid, _color_layer = create_test_grid()
data = grid.grid_data
# Compute Dijkstra map from position (5, 5)
root_x, root_y = 5, 5
print(f"Computing Dijkstra map from root ({root_x}, {root_y})")
grid.compute_dijkstra(root_x, root_y)
dmap = data.get_dijkstra_map(root=(root_x, root_y))
check(dmap is not None, "get_dijkstra_map returned None")
check((dmap.root.x, dmap.root.y) == (root_x, root_y),
f"dmap.root {dmap.root} != root ({root_x}, {root_y})")
# Test getting distances to various points
# (x, y, expected) -- expected None means "unreachable"
test_points = [
(5, 5), # Root position (should be 0)
(6, 5), # Adjacent (should be 1)
(7, 5), # Two steps away
(15, 15), # Far corner
(10, 10), # On a wall (should be unreachable)
(5, 5, 0.0), # Root position
(6, 5, 1.0), # Adjacent
(7, 5, 2.0), # Two steps away
(15, 15, None), # Far corner: reachable, distance checked below
(10, 10, None), # On a wall: unreachable
]
print("\nDistances from root:")
for x, y in test_points:
distance = grid.get_dijkstra_distance(x, y)
for x, y, expected in test_points:
distance = dmap.distance((x, y))
if distance is None:
print(f" ({x:2}, {y:2}): UNREACHABLE")
else:
print(f" ({x:2}, {y:2}): {distance:.1f}")
if (x, y) == (10, 10):
check(distance is None, "wall cell (10, 10) should be unreachable")
elif (x, y) == (15, 15):
# Reachable by going around the walls; exact cost depends on
# diagonal_cost, but it must be finite and clearly non-trivial.
check(distance is not None, "(15, 15) should be reachable around the walls")
if distance is not None:
check(distance > 10.0, f"(15, 15) distance {distance} implausibly short")
else:
check(distance is not None, f"({x}, {y}) should be reachable")
if distance is not None:
check(abs(distance - expected) < 0.001,
f"({x}, {y}) distance {distance} != expected {expected}")
# Test getting paths
print("\nPaths to root:")
for x, y in [(15, 5), (15, 15), (5, 15)]:
path = grid.get_dijkstra_path(x, y)
if path:
print(f" From ({x}, {y}): {len(path)} steps")
# Show first few steps
for i, (px, py) in enumerate(path[:3]):
print(f" Step {i+1}: ({px}, {py})")
if len(path) > 3:
print(f" ... {len(path)-3} more steps")
path = dmap.path_from((x, y))
steps = list(path) if path else []
if steps:
print(f" From ({x}, {y}): {len(steps)} steps")
for i, step in enumerate(steps[:3]):
print(f" Step {i+1}: ({step.x}, {step.y})")
if len(steps) > 3:
print(f" ... {len(steps)-3} more steps")
else:
print(f" From ({x}, {y}): No path found")
def test_libtcod_interface():
"""Test the libtcod module interface"""
print("\n=== Testing libtcod Interface ===")
grid = create_test_grid()
# Use libtcod functions
print("Using libtcod.dijkstra_* functions:")
# Create dijkstra context (returns grid)
dijkstra = libtcod.dijkstra_new(grid)
print(f"Created Dijkstra context: {type(dijkstra)}")
# Compute from a position
libtcod.dijkstra_compute(grid, 10, 2)
print("Computed Dijkstra map from (10, 2)")
# Get distance using libtcod
distance = libtcod.dijkstra_get_distance(grid, 10, 17)
check(len(steps) > 0, f"no path from ({x}, {y}) back to root")
if steps:
# An AStarPath walks origin -> destination: successive steps must be
# adjacent, the first step must neighbor the query position, and the
# last step must be the root. (This is what find_path() returns.)
cells = [(int(s.x), int(s.y)) for s in steps]
check(adjacent(cells[0], (x, y)),
f"path from ({x}, {y}): first step {cells[0]} is not adjacent to the start")
check(cells[-1] == (root_x, root_y),
f"path from ({x}, {y}) ends at {cells[-1]}, not the root ({root_x}, {root_y})")
for a, b in zip(cells, cells[1:]):
check(adjacent(a, b),
f"path from ({x}, {y}) jumps from {a} to {b} (not adjacent)")
for cell in cells:
check(grid.at(cell[0], cell[1]).walkable,
f"path from ({x}, {y}) crosses wall at {cell}")
def test_dijkstra_map_interface():
"""Test the DijkstraMap object interface (successor to the old libtcod module)"""
print("\n=== Testing DijkstraMap Interface ===")
grid, _color_layer = create_test_grid()
data = grid.grid_data
# Create dijkstra map (successor of libtcod.dijkstra_new + dijkstra_compute)
dijkstra = data.get_dijkstra_map(root=(10, 2))
print(f"Created Dijkstra map: {type(dijkstra).__name__}, root={dijkstra.root}")
check(isinstance(dijkstra, mcrfpy.DijkstraMap),
"get_dijkstra_map did not return a DijkstraMap")
# Get distance (successor of libtcod.dijkstra_get_distance)
distance = dijkstra.distance((10, 17))
print(f"Distance to (10, 17): {distance}")
# Get path using libtcod
path = libtcod.dijkstra_path_to(grid, 10, 17)
print(f"Path from (10, 17) to root: {len(path) if path else 0} steps")
check(distance is not None, "(10, 17) should be reachable from (10, 2)")
# Get path (successor of libtcod.dijkstra_path_to)
path = dijkstra.path_from((10, 17))
steps = [(int(s.x), int(s.y)) for s in path] if path else []
print(f"Path from (10, 17) to root: {len(steps)} steps -> {steps[:4]}...")
check(len(steps) > 0, "no path from (10, 17) to root (10, 2)")
if steps:
check((path.origin.x, path.origin.y) == (10, 17), "AStarPath.origin is not the query pos")
check((path.destination.x, path.destination.y) == (10, 2),
"AStarPath.destination is not the root")
check(adjacent(steps[0], (10, 17)),
f"first step {steps[0]} is not adjacent to the start (10, 17)")
check(steps[-1] == (10, 2), f"path ends at {steps[-1]}, not root (10, 2)")
# step_from(pos) is documented as "single step from position toward root":
# it must be the same as the first element of path_from(pos), and adjacent.
step = dijkstra.step_from((10, 17))
print(f"step_from((10, 17)) -> {step}")
check(step is not None, "step_from returned None for a reachable cell")
if step is not None:
check(adjacent((int(step.x), int(step.y)), (10, 17)),
f"step_from((10, 17)) returned ({int(step.x)}, {int(step.y)}), which is not adjacent")
# Maps are cached per-root; asking again yields an equivalent map
again = data.get_dijkstra_map(root=(10, 2))
check((again.root.x, again.root.y) == (10, 2), "cached map lost its root")
check(again.distance((10, 17)) == distance, "cached map distance changed")
def test_multi_target_scenario():
"""Test fleeing/approaching multiple targets"""
print("\n=== Testing Multi-Target Scenario ===")
grid = create_test_grid()
grid, color_layer = create_test_grid()
data = grid.grid_data
# Place three "threats" and compute their Dijkstra maps
threats = [(3, 3), (17, 3), (10, 17)]
print("Computing threat distances...")
threat_distances = []
for i, (tx, ty) in enumerate(threats):
# Mark threat position
cell = grid.at(tx, ty)
cell.tilesprite = 84 # T for threat
grid._color_layer.set(tx, ty, mcrfpy.Color(255, 0, 0))
color_layer.set((tx, ty), mcrfpy.Color(255, 0, 0))
# Compute Dijkstra from this threat
grid.compute_dijkstra(tx, ty)
dmap = data.get_dijkstra_map(root=(tx, ty))
# Store distances for all cells
distances = {}
for y in range(grid.grid_h):
for x in range(grid.grid_w):
d = grid.get_dijkstra_distance(x, y)
for y in range(data.grid_h):
for x in range(data.grid_w):
d = dmap.distance((x, y))
if d is not None:
distances[(x, y)] = d
threat_distances.append(distances)
print(f" Threat {i+1} at ({tx}, {ty}): {len(distances)} reachable cells")
check(distances.get((tx, ty)) == 0.0,
f"threat {i+1} root ({tx}, {ty}) should have distance 0")
check(len(distances) > 100,
f"threat {i+1} only reached {len(distances)} cells; map looks broken")
check((10, 10) not in distances,
f"threat {i+1} reached wall cell (10, 10)")
# Find safest position (farthest from all threats)
print("\nFinding safest position...")
best_pos = None
best_min_dist = 0
for y in range(grid.grid_h):
for x in range(grid.grid_w):
for y in range(data.grid_h):
for x in range(data.grid_w):
# Skip if not walkable
if not grid.at(x, y).walkable:
continue
# Get minimum distance to any threat
min_dist = float('inf')
for threat_dist in threat_distances:
if (x, y) in threat_dist:
min_dist = min(min_dist, threat_dist[(x, y)])
# Track best position
if min_dist > best_min_dist and min_dist != float('inf'):
best_min_dist = min_dist
best_pos = (x, y)
check(best_pos is not None, "no safest position found (all cells unreachable?)")
if best_pos:
print(f"Safest position: {best_pos} (min distance to threats: {best_min_dist:.1f})")
check(grid.at(best_pos[0], best_pos[1]).walkable, "safest position is a wall")
check(best_min_dist > 0, "safest position sits on top of a threat")
# Mark safe position
cell = grid.at(best_pos[0], best_pos[1])
cell.tilesprite = 83 # S for safe
grid._color_layer.set(best_pos[0], best_pos[1], mcrfpy.Color(0, 255, 0))
color_layer.set((best_pos[0], best_pos[1]), mcrfpy.Color(0, 255, 0))
# Multi-source Dijkstra: one map rooted at ALL threats at once. Its distance
# to any cell must equal the min over the individual per-threat maps.
multi = data.get_dijkstra_map(roots=threats)
mismatches = 0
for (x, y), _ in list(threat_distances[0].items())[:200]:
expected = min(td[(x, y)] for td in threat_distances if (x, y) in td)
got = multi.distance((x, y))
if got is None or abs(got - expected) > 0.001:
mismatches += 1
check(mismatches == 0,
f"multi-source Dijkstra disagrees with min-of-single-source on {mismatches} cells")
def main():
print("McRogueFace Dijkstra Pathfinding Test")
print("=====================================")
# Set up scene so the grid is actually attached to a live UI tree
dijkstra_test = mcrfpy.Scene("dijkstra_test")
grid, _color_layer = create_test_grid()
grid.pos = (0, 0)
grid.size = (400, 400)
ui = dijkstra_test.children
ui.append(grid)
title = mcrfpy.Caption(text="Dijkstra Pathfinding Test", pos=(10, 10))
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
dijkstra_test.activate()
def run_test(timer, runtime):
"""Timer callback to run tests after scene loads"""
test_basic_dijkstra()
test_libtcod_interface()
test_dijkstra_map_interface()
test_multi_target_scenario()
print("\n=== Dijkstra Implementation Test Complete ===")
print("✓ Basic Dijkstra computation works")
print("✓ Distance queries work")
print("✓ Path finding works")
print("✓ libtcod interface works")
print("✓ Multi-target scenarios work")
# Take screenshot
try:
from mcrfpy import automation
automation.screenshot("dijkstra_test.png")
print("\nScreenshot saved: dijkstra_test.png")
except:
pass
if failures:
print(f"\n{len(failures)} check(s) FAILED:")
for f in failures:
print(f" - {f}")
print("FAIL")
sys.exit(1)
print("Basic Dijkstra computation works")
print("Distance queries work")
print("Path finding works")
print("DijkstraMap interface works")
print("Multi-target scenarios work")
print("PASS")
sys.exit(0)
# Main execution
print("McRogueFace Dijkstra Pathfinding Test")
print("=====================================")
# Set up scene
grid = create_test_grid()
ui = dijkstra_test.children
ui.append(grid)
# Add title
title = mcrfpy.Caption(pos=(10, 10), text="Dijkstra Pathfinding Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Set timer to run tests
test_timer = mcrfpy.Timer("test", run_test, 100, once=True)
# Show scene
dijkstra_test.activate()
if __name__ == "__main__":
main()

View file

@ -1,133 +1,164 @@
#!/usr/bin/env python3
"""Test that method documentation is properly accessible in Python."""
"""Test that method documentation is properly accessible in Python.
Rot repair: this test used to only *print* what it found (so a missing
docstring silently "passed"), and it probed the pre-#350 API surface
(setScene/createScene/sceneUI/currentScene, the module-level audio
functions). It now asserts against the current mcrfpy API and fails loudly
on any missing docstring.
"""
import mcrfpy
import sys
import types
import io
import contextlib
failures = []
def check(condition, message):
if condition:
print(f" PASS: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
def test_module_doc():
"""Test module-level documentation."""
print("=== Module Documentation ===")
print(f"Module: {mcrfpy.__name__}")
print(f"Doc: {mcrfpy.__doc__[:100]}..." if mcrfpy.__doc__ else "No module doc")
doc = mcrfpy.__doc__
check(bool(doc and doc.strip()), "mcrfpy has a module docstring")
check(bool(doc and 'McRogueFace' in doc), "module docstring names McRogueFace")
print()
def test_method_docs():
"""Test method documentation."""
"""Every public module-level function must be documented."""
print("=== Method Documentation ===")
# Test main API methods
# Current module-level API (audio moved to Sound/Music/SoundBuffer classes;
# setScene/createScene/currentScene/sceneUI replaced by mcrfpy.Scene).
methods = [
'createSoundBuffer', 'loadMusic', 'setMusicVolume', 'setSoundVolume',
'playSound', 'getMusicVolume', 'getSoundVolume', 'sceneUI',
'currentScene', 'setScene', 'createScene', 'keypressScene',
'exit', 'setScale', 'find', 'findAll',
'getMetrics'
'bresenham', 'end_benchmark', 'exit', 'find', 'find_all', 'get_metrics',
'lock', 'log_benchmark', 'set_dev_console', 'set_scale',
'start_benchmark', 'step',
]
for method_name in methods:
if hasattr(mcrfpy, method_name):
method = getattr(mcrfpy, method_name)
doc = method.__doc__
if doc:
# Extract first line of docstring
first_line = doc.strip().split('\n')[0]
print(f"{method_name}: {first_line}")
else:
print(f"{method_name}: NO DOCUMENTATION")
check(hasattr(mcrfpy, method_name), f"mcrfpy.{method_name} exists")
method = getattr(mcrfpy, method_name, None)
doc = getattr(method, '__doc__', None)
check(bool(doc and doc.strip()), f"mcrfpy.{method_name} has documentation")
# Nothing public may be undocumented, even if not listed above.
for name in dir(mcrfpy):
if name.startswith('_'):
continue
obj = getattr(mcrfpy, name)
if isinstance(obj, types.BuiltinFunctionType):
check(bool(obj.__doc__), f"module function {name} has documentation")
print()
def test_class_docs():
"""Test class documentation."""
print("=== Class Documentation ===")
classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer']
classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'GridView', 'GridData',
'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer', 'Scene']
for class_name in classes:
if hasattr(mcrfpy, class_name):
cls = getattr(mcrfpy, class_name)
doc = cls.__doc__
if doc:
# Extract first line
first_line = doc.strip().split('\n')[0]
print(f"{class_name}: {first_line[:80]}...")
else:
print(f"{class_name}: NO DOCUMENTATION")
check(hasattr(mcrfpy, class_name), f"mcrfpy.{class_name} exists")
cls = getattr(mcrfpy, class_name, None)
doc = getattr(cls, '__doc__', None)
check(bool(doc and doc.strip()), f"class {class_name} has documentation")
if doc:
print(f" {class_name}: {doc.strip().splitlines()[0][:70]}")
print()
def test_property_docs():
"""Test property documentation."""
"""Every getset property on the core drawables must be documented."""
print("=== Property Documentation ===")
# Test Frame properties
if hasattr(mcrfpy, 'Frame'):
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline', 'children', 'visible', 'z_index']
print("Frame properties:")
for prop_name in frame_props:
prop = getattr(mcrfpy.Frame, prop_name, None)
if prop and hasattr(prop, '__doc__'):
print(f" {prop_name}: {prop.__doc__}")
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline',
'children', 'visible', 'z_index']
for prop_name in frame_props:
prop = getattr(mcrfpy.Frame, prop_name, None)
check(prop is not None, f"Frame.{prop_name} exists")
check(bool(prop is not None and prop.__doc__),
f"Frame.{prop_name} has documentation")
# No getset descriptor on the core types may be undocumented.
for class_name in ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity',
'Color', 'Vector', 'Timer']:
cls = getattr(mcrfpy, class_name)
for prop_name, prop in vars(cls).items():
if isinstance(prop, types.GetSetDescriptorType):
check(bool(prop.__doc__),
f"{class_name}.{prop_name} has documentation")
print()
def test_method_signatures():
"""Test that methods have correct signatures in docs."""
"""Test that docstrings carry a usable signature line."""
print("=== Method Signatures ===")
# Check a few key methods
if hasattr(mcrfpy, 'setScene'):
doc = mcrfpy.setScene.__doc__
if doc and 'setScene(scene: str, transition: str = None, duration: float = 0.0)' in doc:
print("✓ setScene signature correct")
else:
print("✗ setScene signature incorrect or missing")
if hasattr(mcrfpy, 'Timer'):
doc = mcrfpy.Timer.__doc__
if doc and 'Timer' in doc:
print("+ Timer class documentation present")
else:
print("x Timer class documentation missing")
if hasattr(mcrfpy, 'find'):
doc = mcrfpy.find.__doc__
if doc and 'find(name: str, scene: str = None)' in doc:
print("✓ find signature correct")
else:
print("✗ find signature incorrect or missing")
doc = mcrfpy.find.__doc__
check(bool(doc and 'find(name: str, scene: str = None)' in doc),
"find() signature present in docstring")
doc = mcrfpy.step.__doc__
check(bool(doc and 'step(' in doc), "step() signature present in docstring")
doc = mcrfpy.Timer.__doc__
check(bool(doc and 'Timer(' in doc), "Timer signature present in class doc")
doc = mcrfpy.Scene.__doc__
check(bool(doc and 'Scene(' in doc), "Scene signature present in class doc")
# Bound method documentation (animation is now an object method, #229).
doc = mcrfpy.Frame.animate.__doc__
check(bool(doc and 'animate(' in doc),
"Frame.animate() signature present in docstring")
print()
def test_help_output():
"""Test Python help() function output."""
print("=== Help Function Test ===")
print("Testing help(mcrfpy.setScene):")
import io
import contextlib
# Capture help output
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
help(mcrfpy.setScene)
help(mcrfpy.find)
help_text = buffer.getvalue()
if 'transition to a different scene' in help_text:
print("✓ Help text contains expected documentation")
else:
print("✗ Help text missing expected documentation")
check('Find the first UI element' in help_text,
"help(mcrfpy.find) contains its documentation")
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
help(mcrfpy.Frame)
help_text = buffer.getvalue()
check('Frame' in help_text and 'animate' in help_text,
"help(mcrfpy.Frame) lists the class and its methods")
print()
def main():
"""Run all documentation tests."""
print("McRogueFace Documentation Tests")
print("===============================\n")
test_module_doc()
test_method_docs()
test_class_docs()
test_property_docs()
test_method_signatures()
test_help_output()
if failures:
print(f"\nFAIL: {len(failures)} documentation check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nDocumentation tests complete!")
print("PASS")
sys.exit(0)
if __name__ == '__main__':
main()
main()

View file

@ -5,20 +5,32 @@ Test Entity Animation
Isolated test for entity position animation.
No perspective, just basic movement in a square pattern.
Headless: mcrfpy.step(dt) is the only clock, so the waypoint sequence is driven
explicitly instead of by keyboard input + a free-running game loop (#350/#341).
"""
import mcrfpy
import sys
failures = []
def check(condition, message):
if not condition:
print(f"FAIL: {message}")
failures.append(message)
return condition
# Create scene
test_anim = mcrfpy.Scene("test_anim")
# Create simple grid
grid = mcrfpy.Grid(grid_w=15, grid_h=15)
grid = mcrfpy.Grid(grid_size=(15, 15), pos=(100, 100), size=(450, 450))
grid.fill_color = mcrfpy.Color(20, 20, 30)
# Add a color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add a color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize all cells as walkable floors
for y in range(15):
@ -26,7 +38,7 @@ for y in range(15):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
# Mark the path we'll follow with different color
path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
@ -35,17 +47,16 @@ path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
(5,9), (5,8), (5,7), (5,6)]
for x, y in path_cells:
color_layer.set(x, y, mcrfpy.Color(120, 120, 150))
color_layer.set((x, y), mcrfpy.Color(120, 120, 150))
# Create entity at start position
entity = mcrfpy.Entity((5, 5), grid=grid)
entity = mcrfpy.Entity(grid_pos=(5, 5))
entity.sprite_index = 64 # @
grid.entities.append(entity)
# UI setup
ui = test_anim.children
ui.append(grid)
grid.pos = (100, 100)
grid.size = (450, 450) # 15 * 30 pixels per cell
# Title
title = mcrfpy.Caption(pos=(200, 20), text="Entity Animation Test - Square Path")
@ -53,12 +64,13 @@ title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Status display
status = mcrfpy.Caption(pos=(100, 50), text="Press SPACE to start animation | Q to quit")
status = mcrfpy.Caption(pos=(100, 50), text="Animating square path")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
# Position display
pos_display = mcrfpy.Caption(pos=(100, 70), text=f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})")
pos_display = mcrfpy.Caption(pos=(100, 70),
text=f"Entity Position: ({entity.draw_pos.x:.2f}, {entity.draw_pos.y:.2f})")
pos_display.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(pos_display)
@ -67,139 +79,118 @@ anim_info = mcrfpy.Caption(pos=(400, 70), text="Animation: Not started")
anim_info.fill_color = mcrfpy.Color(100, 255, 255)
ui.append(anim_info)
# Debug info
debug_info = mcrfpy.Caption(pos=(100, 570), text="Debug: Waiting...")
debug_info.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(debug_info)
# Animation state
current_waypoint = 0
animating = False
waypoints = [(5,5), (10,5), (10,10), (5,10), (5,5)]
completed = [] # (property, final_value) tuples from animation callbacks
def update_position_display(timer, runtime):
"""Update position display every 200ms"""
pos_display.text = f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})"
def on_anim_done(target, prop, value):
"""Animation completion callback: (target, property, final_value)"""
completed.append((prop, value))
# Check if entity is at expected position
if animating and current_waypoint > 0:
target = waypoints[current_waypoint - 1]
distance = ((entity.x - target[0])**2 + (entity.y - target[1])**2)**0.5
debug_info.text = f"Debug: Distance to target {target}: {distance:.3f}"
def animate_to_waypoint(index):
"""Animate the entity's draw position to waypoints[index]."""
target_x, target_y = waypoints[index]
duration = 2.0
def animate_to_next_waypoint():
"""Animate to the next waypoint"""
global current_waypoint, animating
if current_waypoint >= len(waypoints):
status.text = "Animation complete! Press SPACE to restart"
anim_info.text = "Animation: Complete"
animating = False
current_waypoint = 0
return
target_x, target_y = waypoints[current_waypoint]
# Log what we're doing
print(f"Animating from ({entity.x}, {entity.y}) to ({target_x}, {target_y})")
# Update status
status.text = f"Moving to waypoint {current_waypoint + 1}/{len(waypoints)}: ({target_x}, {target_y})"
print(f"Animating from ({entity.draw_pos.x}, {entity.draw_pos.y}) to ({target_x}, {target_y})")
status.text = f"Moving to waypoint {index + 1}/{len(waypoints)}: ({target_x}, {target_y})"
anim_info.text = f"Animation: Active (target: {target_x}, {target_y})"
# Create animations - ensure we're using floats
duration = 2.0 # 2 seconds per segment
# Try different approaches to see what works
# Approach 1: Direct property animation
anim_x = mcrfpy.Animation("x", float(target_x), duration, "linear")
anim_y = mcrfpy.Animation("y", float(target_y), duration, "linear")
# Start animations
anim_x.start(entity)
anim_y.start(entity)
# Log animation details
print(f"Started animations: x to {float(target_x)}, y to {float(target_y)}, duration: {duration}s")
current_waypoint += 1
# Schedule next waypoint
global next_waypoint_timer
next_waypoint_timer = mcrfpy.Timer("next_waypoint", lambda t, r: animate_to_next_waypoint(), int(duration * 1000 + 100), once=True)
# 'draw_x'/'draw_y' are the tile-space draw coordinates ('x'/'y' are aliases).
# mcrfpy.Animation is no longer exported; animations are created via .animate().
entity.animate("draw_x", float(target_x), duration, mcrfpy.Easing.LINEAR,
callback=on_anim_done)
entity.animate("draw_y", float(target_y), duration, mcrfpy.Easing.LINEAR,
callback=on_anim_done)
return duration
def start_animation():
"""Start or restart the animation sequence"""
global current_waypoint, animating
# Reset entity position
entity.x = 5
entity.y = 5
# Reset state
current_waypoint = 0
animating = True
print("Starting animation sequence...")
# Start first animation
animate_to_next_waypoint()
def step_seconds(seconds, dt=0.1):
"""Advance the headless clock; the engine never advances time on its own."""
for _ in range(int(round(seconds / dt))):
mcrfpy.step(dt)
def test_immediate_position():
"""Test setting position directly"""
print(f"Before: entity at ({entity.x}, {entity.y})")
entity.x = 7
entity.y = 7
print(f"After direct set: entity at ({entity.x}, {entity.y})")
# Try with animation to same position
anim_x = mcrfpy.Animation("x", 9.0, 1.0, "linear")
anim_x.start(entity)
print("Started animation to x=9.0")
"""Test setting position directly (no animation)."""
# #295: grid_pos (logical cell) is DECOUPLED from draw_pos (visual position).
# Setting one does not move the other; they are assigned independently.
entity.grid_pos = (7, 7)
check(tuple(entity.cell_pos) == (7, 7),
f"direct grid_pos set: expected cell (7,7), got {tuple(entity.cell_pos)}")
check((entity.draw_pos.x, entity.draw_pos.y) == (5.0, 5.0),
f"grid_pos is decoupled from draw_pos; draw_pos should be unchanged at "
f"(5,5), got {entity.draw_pos}")
# Input handler
def handle_input(key, state):
if state != mcrfpy.InputState.PRESSED:
return
entity.draw_pos = (7.0, 7.0)
check((entity.draw_pos.x, entity.draw_pos.y) == (7.0, 7.0),
f"direct draw_pos set: expected (7.0, 7.0), got {entity.draw_pos}")
if key == mcrfpy.Key.Q:
print("Exiting test...")
sys.exit(0)
elif key == mcrfpy.Key.SPACE:
if not animating:
start_animation()
else:
print("Animation already in progress!")
elif key == mcrfpy.Key.T:
# Test immediate position change
test_immediate_position()
elif key == mcrfpy.Key.R:
# Reset position
entity.x = 5
entity.y = 5
print(f"Reset entity to ({entity.x}, {entity.y})")
# Set scene
test_anim.activate()
test_anim.on_key = handle_input
# Start position update timer
update_pos_timer = mcrfpy.Timer("update_pos", update_position_display, 200)
# No perspective (omniscient view)
grid.perspective = -1
# Animation to a new x, driven by explicit steps
entity.animate("draw_x", 9.0, 1.0, mcrfpy.Easing.LINEAR)
step_seconds(0.5)
mid_x = entity.draw_pos.x
check(7.0 < mid_x < 9.0,
f"linear animation should be mid-flight at t=0.5s, draw_x={mid_x}")
step_seconds(0.6)
check(abs(entity.draw_pos.x - 9.0) < 0.01,
f"animation should land on draw_x=9.0, got {entity.draw_pos.x}")
print("Entity Animation Test")
print("====================")
print("This test animates an entity in a square pattern:")
print("(5,5) → (10,5) → (10,10) → (5,10) → (5,5)")
print("(5,5) -> (10,5) -> (10,10) -> (5,10) -> (5,5)")
print()
print("Controls:")
print(" SPACE - Start animation")
print(" T - Test immediate position change")
print(" R - Reset position to (5,5)")
print(" Q - Quit")
print()
print("The position display updates every 200ms")
print("Watch the console for animation logs")
test_anim.activate()
grid.perspective = None # omniscient view (perspective takes an Entity or None now)
# --- Direct position assignment ---------------------------------------------
test_immediate_position()
# --- Square-path waypoint animation -----------------------------------------
entity.grid_pos = (5, 5)
entity.draw_pos = (5.0, 5.0) # decoupled from grid_pos; reset both
completed.clear()
for i, (wx, wy) in enumerate(waypoints):
start = (entity.draw_pos.x, entity.draw_pos.y)
duration = animate_to_waypoint(i)
# Halfway through: entity must be strictly between start and target on any
# axis that actually changes (proves interpolation, not a snap).
step_seconds(duration / 2)
half = (entity.draw_pos.x, entity.draw_pos.y)
for axis, s, t, h in (("x", start[0], wx, half[0]), ("y", start[1], wy, half[1])):
if s != t:
check(min(s, t) < h < max(s, t),
f"waypoint {i}: draw_{axis} should be interpolating between "
f"{s} and {t} at half-time, got {h}")
# Finish the segment (plus a little slack for the final step)
step_seconds(duration / 2 + 0.2)
end = entity.draw_pos
check(abs(end.x - wx) < 0.01 and abs(end.y - wy) < 0.01,
f"waypoint {i}: expected draw_pos ({wx}, {wy}), got ({end.x}, {end.y})")
pos_display.text = f"Entity Position: ({end.x:.2f}, {end.y:.2f})"
anim_info.text = "Animation: Complete"
# Each waypoint fires two completion callbacks (draw_x and draw_y)
check(len(completed) == 2 * len(waypoints),
f"expected {2 * len(waypoints)} animation callbacks, got {len(completed)}")
# The entity ends where it started -- a closed square
check(abs(entity.draw_pos.x - 5.0) < 0.01 and abs(entity.draw_pos.y - 5.0) < 0.01,
f"square path should end at (5, 5), got {entity.draw_pos}")
# Animating draw_pos does not move the entity's logical cell (#313 contract:
# draw_pos is the visual position, cell_pos/grid_pos is the authoritative cell).
check(tuple(entity.cell_pos) == (5, 5),
f"logical cell should still be (5,5), got {tuple(entity.cell_pos)}")
if failures:
print(f"\n{len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -1,14 +1,24 @@
#!/usr/bin/env python3
"""Test the new Entity.path_to() method"""
"""Test the Entity.path_to() method"""
import mcrfpy
import sys
print("Testing Entity.path_to() method...")
print("=" * 50)
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS {label}")
else:
print(f" FAIL {label}: {detail}")
failures.append(label)
# Create scene and grid
path_test = mcrfpy.Scene("path_test")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10))
# Set up a simple map with some walls
for y in range(10):
@ -24,48 +34,76 @@ for x, y in walls:
# Create entity
entity = mcrfpy.Entity((2, 2), grid=grid)
print(f"Entity at: ({entity.x}, {entity.y})")
# NOTE: entity.x/.y are pixel draw coords now; the logical cell is entity.cell_pos.
start = (int(entity.cell_pos.x), int(entity.cell_pos.y))
print(f"Entity at cell: {start}")
check("entity starts at (2, 2)", start == (2, 2), f"got {start}")
# Test 1: Simple path
def validate_path(path, target):
"""A path must be a contiguous, wall-free walk ending on the target."""
if not path:
return "path is empty"
if tuple(path[-1]) != target:
return f"path does not end at {target}: {path[-1]}"
prev = start
for step in path:
step = tuple(step)
if step in walls:
return f"path walks through wall {step}"
dx, dy = abs(step[0] - prev[0]), abs(step[1] - prev[1])
if max(dx, dy) != 1:
return f"non-adjacent step {prev} -> {step}"
prev = step
return None
# Test 1: Simple path (positional x, y)
print("\nTest 1: Path to (6, 6)")
try:
path = entity.path_to(6, 6)
print(f" Path: {path}")
print(f" Length: {len(path)} steps")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
path = entity.path_to(6, 6)
print(f" Path: {path}")
err = validate_path(path, (6, 6))
check("path_to(6, 6) returns a valid walk to (6, 6)", err is None, err or "")
# Test 2: Path with target_x/target_y keywords
print("\nTest 2: Path using keyword arguments")
try:
path = entity.path_to(target_x=7, target_y=7)
print(f" Path: {path}")
print(f" Length: {len(path)} steps")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
# Test 2: Path using keyword arguments.
# API CHANGE: the old target_x=/target_y= keywords are gone; the position-spec
# parser now accepts pos=(x, y) (or a bare tuple/Vector).
print("\nTest 2: Path using keyword argument pos=(7, 7)")
path = entity.path_to(pos=(7, 7))
print(f" Path: {path}")
err = validate_path(path, (7, 7))
check("path_to(pos=(7, 7)) returns a valid walk to (7, 7)", err is None, err or "")
# Test 3: Path to unreachable location
# Test 3: Path to current position is empty (already there)
print("\nTest 3: Path to current position")
try:
path = entity.path_to(2, 2)
print(f" Path: {path}")
print(f" Length: {len(path)} steps")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
path = entity.path_to(2, 2)
print(f" Path: {path}")
check("path_to(own position) is empty", path == [], f"got {path}")
# Test 4: Error cases
print("\nTest 4: Error handling")
try:
# Out of bounds
path = entity.path_to(15, 15)
print(" ✗ Should have failed for out of bounds")
entity.path_to(15, 15)
check("out-of-bounds target raises ValueError", False, "no exception raised")
except ValueError as e:
print(f" ✓ Correctly caught out of bounds: {e}")
check("out-of-bounds target raises ValueError", True)
print(f" ({e})")
except Exception as e:
print(f" ✗ Wrong exception type: {e}")
check("out-of-bounds target raises ValueError", False,
f"wrong exception type {type(e).__name__}: {e}")
# Test 5: Unreachable target -> empty path (wall off a cell completely)
print("\nTest 5: Unreachable target")
for x, y in [(8, 0), (8, 1), (9, 1)]:
grid.at(x, y).walkable = False
path = entity.path_to(9, 0)
print(f" Path: {path}")
check("walled-off target yields empty path", path == [], f"got {path}")
print("\n" + "=" * 50)
print("Entity.path_to() testing complete!")
if failures:
print(f"FAILED: {len(failures)} check(s): {failures}")
sys.exit(1)
print("Entity.path_to() testing complete!")
print("PASS")
sys.exit(0)

View file

@ -2,25 +2,36 @@
"""Test edge cases for Entity.path_to() method"""
import mcrfpy
import sys
failures = []
def check(condition, message):
"""Record a pass/fail instead of just printing it."""
if condition:
print(" [ok] %s" % message)
else:
print(" [FAIL] %s" % message)
failures.append(message)
print("Testing Entity.path_to() edge cases...")
print("=" * 50)
# Test 1: Entity without grid
print("Test 1: Entity not in grid")
entity = mcrfpy.Entity(grid_pos=(5, 5))
try:
entity = mcrfpy.Entity((5, 5))
path = entity.path_to(8, 8)
print(" ✗ Should have failed for entity not in grid")
check(False, "path_to() on a grid-less entity should raise ValueError, got %r" % (path,))
except ValueError as e:
print(f" ✓ Correctly caught no grid error: {e}")
check(True, "correctly raised ValueError with no grid: %s" % e)
except Exception as e:
print(f" ✗ Wrong exception type: {e}")
check(False, "wrong exception type: %s: %s" % (type(e).__name__, e))
# Test 2: Entity in grid with walls blocking path
print("\nTest 2: Completely blocked path")
blocked_test = mcrfpy.Scene("blocked_test")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5))
# Make all tiles walkable first
for y in range(5):
@ -31,25 +42,28 @@ for y in range(5):
for x in range(5):
grid.at(x, 2).walkable = False
entity = mcrfpy.Entity((1, 1), grid=grid)
entity = mcrfpy.Entity(grid_pos=(1, 1), grid=grid)
try:
path = entity.path_to(1, 4)
if path:
print(f" Path found: {path}")
else:
print(" ✓ No path found (empty list returned)")
except Exception as e:
print(f" ✗ Unexpected error: {e}")
path = entity.path_to(1, 4)
check(path == [], "no path across the full-width wall, got %r" % (path,))
# Test 3: Alternative parameter parsing
# Test 3: Alternative parameter parsing (keyword x/y)
print("\nTest 3: Alternative parameter names")
try:
path = entity.path_to(x=3, y=1)
print(f" Path with x/y params: {path}")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
path = entity.path_to(x=3, y=1)
check(path == [(2, 1), (3, 1)],
"path_to(x=, y=) walked the open row: %r" % (path,))
# The same target via positional args must agree with the keyword form.
check(entity.path_to(3, 1) == path,
"positional path_to(3, 1) matches the keyword form")
print("\n" + "=" * 50)
print("Edge case testing complete!")
if failures:
print("Edge case testing FAILED (%d):" % len(failures))
for f in failures:
print(" - %s" % f)
sys.exit(1)
print("Edge case testing complete!")
print("PASS")
sys.exit(0)

View file

@ -1,7 +1,19 @@
#!/usr/bin/env python3
"""Reproduce the exact failure from dijkstra_demo_final.py"""
"""Reproduce the exact failure from dijkstra_demo_final.py
Original intent: a bulk per-cell mutation loop (walkable/transparent/color over
every cell of a Grid) appeared to set a Python exception that was NOT raised at
its source, but instead surfaced later on an unrelated line (the `walls.append`
loop). This test drives that exact pattern and asserts that:
1. the pattern completes without raising, and
2. no *stale/pending* exception leaks out onto a later, unrelated statement.
API note: GridPoint no longer has `.color` -- per-cell color now lives on a
ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)).
"""
import mcrfpy
import sys
print("Reproducing exact failure pattern...")
print("=" * 50)
@ -10,87 +22,123 @@ print("=" * 50)
WALL_COLOR = mcrfpy.Color(60, 30, 30)
FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
GRID_W, GRID_H = 25, 15
failures = []
def test_exact_pattern():
"""Exact code from dijkstra_demo_final.py"""
"""Exact code from dijkstra_demo_final.py (ported to the current API)"""
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
# Create grid
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Per-cell color now lives on a ColorLayer, not on GridPoint
color_layer = mcrfpy.ColorLayer(name="floor")
grid.add_layer(color_layer)
# Initialize all as floor
for y in range(15):
for x in range(25):
for y in range(GRID_H):
for x in range(GRID_W):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = FLOOR_COLOR
color_layer.set((x, y), FLOOR_COLOR)
# Create an interesting dungeon layout
walls = []
# Room walls
# Top-left room
for x in range(1, 8): walls.append((x, 1))
return grid, walls
return grid, color_layer, walls
print("Test 1: Running exact pattern...")
try:
grid, walls = test_exact_pattern()
print(f" ✓ Success! Created {len(walls)} walls")
grid, color_layer, walls = test_exact_pattern()
print(f" OK Success! Created {len(walls)} walls")
if len(walls) != 7:
failures.append(f"expected 7 walls, got {len(walls)}")
except Exception as e:
print(f" ✗ Failed: {type(e).__name__}: {e}")
failures.append(f"exact pattern raised {type(e).__name__}: {e}")
print(f" FAIL Failed: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
print()
print("Test 2: Breaking it down step by step...")
def step(label, fn):
"""Run a step; a raise here is a real failure, not something to swallow."""
try:
result = fn()
print(f" OK {label}")
return result
except Exception as e:
failures.append(f"{label} raised {type(e).__name__}: {e}")
print(f" FAIL {label}: {type(e).__name__}: {e}")
return None
# Step 1: Scene and grid
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
print(" ✓ Step 1: Scene and grid created")
except Exception as e:
print(f" ✗ Step 1 failed: {e}")
def _step1():
mcrfpy.Scene("test2")
return mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
grid = step("Step 1: Scene and grid created", _step1)
# Step 2: Set fill_color
try:
def _step2():
grid.fill_color = mcrfpy.Color(0, 0, 0)
print(" ✓ Step 2: fill_color set")
except Exception as e:
print(f" ✗ Step 2 failed: {e}")
assert grid.fill_color == mcrfpy.Color(0, 0, 0)
step("Step 2: fill_color set", _step2)
# Step 3: Nested loops with grid.at
try:
for y in range(15):
for x in range(25):
# Step 3: Nested loops with grid.at + ColorLayer.set (the suspect loop)
layer = mcrfpy.ColorLayer(name="floor2")
grid.add_layer(layer)
def _step3():
for y in range(GRID_H):
for x in range(GRID_W):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = FLOOR_COLOR
print(" ✓ Step 3: Nested loops completed")
except Exception as e:
print(f" ✗ Step 3 failed: {e}")
layer.set((x, y), FLOOR_COLOR)
step("Step 3: Nested loops completed", _step3)
# The mutations must have actually landed (not silently dropped)
if not (grid.at(0, 0).walkable and grid.at(GRID_W - 1, GRID_H - 1).walkable):
failures.append("Step 3: walkable was not persisted by the nested loop")
if not grid.at(GRID_W - 1, GRID_H - 1).transparent:
failures.append("Step 3: transparent was not persisted by the nested loop")
if layer.at((GRID_W - 1, GRID_H - 1)) != FLOOR_COLOR:
failures.append("Step 3: ColorLayer color was not persisted by the nested loop")
# Step 4: Create walls list
try:
walls = []
print(" ✓ Step 4: walls list created")
except Exception as e:
print(f" ✗ Step 4 failed: {e}")
def _step4():
return []
walls = step("Step 4: walls list created", _step4)
# Step 5: The failing line
try:
# Step 5: The line that used to blow up with an exception raised by *step 3*.
# This is the heart of the regression: after the nested loops, plain Python must
# still work. A stale pending error would explode right here.
def _step5():
for x in range(1, 8): walls.append((x, 1))
print(f" ✓ Step 5: For loop worked, walls = {walls}")
except Exception as e:
print(f" ✗ Step 5 failed: {type(e).__name__}: {e}")
# Check if exception was already pending
import sys
exc_info = sys.exc_info()
print(f" Exception info: {exc_info}")
return walls
step("Step 5: For loop worked", _step5)
if walls != [(x, 1) for x in range(1, 8)]:
failures.append(f"Step 5: walls list wrong: {walls}")
# Explicitly confirm no exception is left pending after the nested loops.
if sys.exc_info()[0] is not None:
failures.append(f"stale pending exception after loops: {sys.exc_info()}")
print()
print("The error occurs at step 5, suggesting an exception was")
print("set during the nested loops but not immediately raised.")
if failures:
print("FAIL: no exception may be deferred out of the nested cell-mutation loops")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("The nested cell-mutation loops raise nothing, persist their writes, and")
print("leave no pending exception to surface on a later line.")
print("PASS")
sys.exit(0)

View file

@ -9,10 +9,13 @@ Tests cover:
"""
import sys
import os
import math
import traceback
# Import the geometry module
sys.path.insert(0, '/home/john/Development/McRogueFace/src/scripts')
# Import the geometry module (src/scripts, relative to this test file)
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.abspath(__file__)), '..', '..', 'src', 'scripts'))
from geometry import (
# Utilities
distance, distance_squared, angle_between, normalize_angle,
@ -601,4 +604,13 @@ def run_all_tests():
print("=" * 50)
if __name__ == "__main__":
run_all_tests()
# Exit contract (#350): headless --exec scripts must call sys.exit() explicitly.
# Any failed assertion is a real failure -> report it and exit nonzero.
try:
run_all_tests()
except Exception:
traceback.print_exc()
print("FAIL")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -66,12 +66,16 @@ def test_apply_threshold_out_of_range():
def test_apply_threshold_returns_self():
"""apply_threshold returns self for chaining"""
"""apply_threshold returns the GridData for chaining
Current contract (#313/#361): the apply_* methods live on GridData, and the
Grid view forwards to it. They return the GridData ("self"), not the view.
"""
grid = mcrfpy.Grid(grid_size=(10, 10))
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
result = grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True)
assert result is grid, "apply_threshold should return self"
assert result is grid.grid_data, "apply_threshold should return the GridData (self)"
print("PASS: test_apply_threshold_returns_self")
@ -159,7 +163,7 @@ def test_apply_ranges_returns_self():
result = grid.apply_ranges(hmap, [
((0.0, 1.0), {"walkable": True}),
])
assert result is grid, "apply_ranges should return self"
assert result is grid.grid_data, "apply_ranges should return the GridData (self)"
print("PASS: test_apply_ranges_returns_self")
@ -275,7 +279,7 @@ def test_chaining():
((0.4, 0.6), {"walkable": True, "transparent": True}),
]))
assert result is grid
assert result is grid.grid_data
print("PASS: test_chaining")

View file

@ -1,27 +1,48 @@
#!/usr/bin/env python3
"""Test Grid background color functionality"""
"""Test Grid background color functionality
API notes (post-#313/#350):
- Grid.background_color was renamed to Grid.fill_color ("Background fill color
(Color). Drawn behind all tiles and entities."). Same property, new name.
- grid.add_layer() takes a layer OBJECT and no keyword arguments; the layer's
ctor takes the kwargs. ColorLayer.set() takes a (x, y) tuple.
- Headless time only advances via mcrfpy.step(); timers never fire on their own.
"""
import mcrfpy
import sys
failures = []
def check(label, actual, expected):
if actual == expected:
print(f"+ {label}: {actual}")
else:
print(f"FAIL {label}: expected {expected}, got {actual}")
failures.append(label)
def rgb(color):
return (color.r, color.g, color.b)
def test_grid_background():
"""Test Grid background color property"""
print("Testing Grid Background Color...")
# Create a test scene
test = mcrfpy.Scene("test")
ui = test.children
# Create a grid with default background
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15))
ui.append(grid)
# Add color layer for some tiles to see the background better
color_layer = grid.add_layer("color", z_index=-1)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
for x in range(5, 15):
for y in range(5, 10):
color_layer.set(x, y, mcrfpy.Color(100, 150, 100))
color_layer.set((x, y), mcrfpy.Color(100, 150, 100))
# Add UI to show current background color
info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150),
fill_color=mcrfpy.Color(40, 40, 40),
@ -38,85 +59,108 @@ def test_grid_background():
color_display.font_size = 12
color_display.fill_color = mcrfpy.Color(200, 200, 200)
info_frame.children.append(color_display)
# Activate the scene
test.activate()
# Track which timer stages actually ran
stages = []
def run_tests(timer, runtime):
"""Run background color tests"""
timer.stop()
stages.append("default")
print("\nTest 1: Default background color")
default_color = grid.background_color
print(f"Default: R={default_color.r}, G={default_color.g}, B={default_color.b}, A={default_color.a}")
default_color = grid.fill_color
print(f"Default: R={default_color.r}, G={default_color.g}, "
f"B={default_color.b}, A={default_color.a}")
color_display.text = f"R:{default_color.r} G:{default_color.g} B:{default_color.b}"
def test_set_color(timer, runtime):
timer.stop()
print("\nTest 2: Set background to blue")
grid.background_color = mcrfpy.Color(20, 40, 100)
new_color = grid.background_color
print(f"+ Set to: R={new_color.r}, G={new_color.g}, B={new_color.b}")
color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}"
def test_animation(timer, runtime):
timer.stop()
print("\nTest 3: Manual color cycling")
# Manually change color to test property is working
colors = [
mcrfpy.Color(200, 20, 20), # Red
mcrfpy.Color(20, 200, 20), # Green
mcrfpy.Color(20, 20, 200), # Blue
]
def test_set_color(timer, runtime):
timer.stop()
stages.append("set")
print("\nTest 2: Set background to blue")
grid.fill_color = mcrfpy.Color(20, 40, 100)
new_color = grid.fill_color
check("set background to (20, 40, 100)", rgb(new_color), (20, 40, 100))
color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}"
color_index = [0] # Use list to allow modification in nested function
colors = [
mcrfpy.Color(200, 20, 20), # Red
mcrfpy.Color(20, 200, 20), # Green
mcrfpy.Color(20, 20, 200), # Blue
]
def cycle_red(t, r):
t.stop()
grid.background_color = colors[0]
c = grid.background_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
print(f"+ Set to Red: R={c.r}, G={c.g}, B={c.b}")
def test_animation(timer, runtime):
timer.stop()
stages.append("cycle")
print("\nTest 3: Manual color cycling")
def cycle_green(t, r):
t.stop()
grid.background_color = colors[1]
c = grid.background_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
print(f"+ Set to Green: R={c.r}, G={c.g}, B={c.b}")
def cycle_red(t, r):
t.stop()
grid.fill_color = colors[0]
c = grid.fill_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
check("cycle to Red", rgb(c), (200, 20, 20))
def cycle_blue(t, r):
t.stop()
grid.background_color = colors[2]
c = grid.background_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
print(f"+ Set to Blue: R={c.r}, G={c.g}, B={c.b}")
def cycle_green(t, r):
t.stop()
grid.fill_color = colors[1]
c = grid.fill_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
check("cycle to Green", rgb(c), (20, 200, 20))
# Cycle through colors
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
def cycle_blue(t, r):
t.stop()
grid.fill_color = colors[2]
c = grid.fill_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
check("cycle to Blue", rgb(c), (20, 20, 200))
def test_complete(timer, runtime):
timer.stop()
print("\nTest 4: Final color check")
final_color = grid.background_color
print(f"Final: R={final_color.r}, G={final_color.g}, B={final_color.b}")
# Cycle through colors
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
print("\n+ Grid background color tests completed!")
print("- Default background color works")
print("- Setting background color works")
print("- Color cycling works")
def test_complete(timer, runtime):
timer.stop()
stages.append("complete")
print("\nTest 4: Final color check")
final_color = grid.fill_color
# The last color set by the cycle stage must have stuck.
check("final color is Blue", rgb(final_color), (20, 20, 200))
sys.exit(0)
# Schedule tests
mcrfpy.Timer("test_set", test_set_color, 1000, once=True)
mcrfpy.Timer("test_anim", test_animation, 2000, once=True)
mcrfpy.Timer("complete", test_complete, 4500, once=True)
# Start tests
# Schedule tests
mcrfpy.Timer("run_tests", run_tests, 100, once=True)
mcrfpy.Timer("test_set", test_set_color, 1000, once=True)
mcrfpy.Timer("test_anim", test_animation, 2000, once=True)
mcrfpy.Timer("complete", test_complete, 4500, once=True)
# Headless: mcrfpy.step() is the only clock. 5 simulated seconds at 50ms/step,
# which covers the 4500ms 'complete' timer with margin.
for _ in range(140):
mcrfpy.step(0.05)
# Force a render so the grid (with its fill_color) actually goes through the
# render path -- the property is a render-time input, not just a stored value.
mcrfpy.automation.screenshot("test_grid_background.png")
for stage in ("default", "set", "cycle", "complete"):
if stage not in stages:
print(f"FAIL: timer stage '{stage}' never ran")
failures.append(f"stage:{stage}")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("\n+ Grid background color tests completed!")
print("- Default background color works")
print("- Setting background color works")
print("- Color cycling works")
print("PASS")
sys.exit(0)
if __name__ == "__main__":
test_grid_background()
test_grid_background()

View file

@ -1,124 +1,110 @@
#!/usr/bin/env python3
"""Test Grid constructor to isolate the PyArg bug"""
"""Test Grid constructor to isolate the PyArg bug
Original intent: the Grid constructor used to set a Python exception without
returning NULL, leaving a stale exception on the interpreter stack. The next
Python operation (e.g. range(1)) then blew up with a cryptic
"new style getargs format" error. This test constructs Grids of many sizes and
verifies that (a) construction succeeds, (b) the resulting Grid is actually the
size that was asked for, and (c) no stale exception is left behind.
API notes (current, post-#313/#361): the Grid constructor takes
grid_size=(w, h); grid_w=/grid_h= are the legacy spelling and still work.
"""
import mcrfpy
import sys
failures = []
def check(cond, msg):
if cond:
print(f" PASS: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
def no_stale_exception(label):
"""Any pending-but-unraised exception trips the next Python operation."""
try:
list(range(1))
except Exception as e:
check(False, f"{label}: stale exception after Grid creation: {type(e).__name__}: {e}")
return False
check(True, f"{label}: no stale exception after Grid creation")
return True
print("Testing Grid constructor PyArg bug...")
print("=" * 50)
# Test 1: Check if exception is set after Grid creation
# Test 1: Check exception state after Grid creation
print("Test 1: Check exception state after Grid creation")
try:
# Clear any existing exception
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
# Create grid with problematic dimensions
print(" Creating Grid(grid_w=25, grid_h=15)...")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
print(" Grid created successfully")
# Check if there's a pending exception
exc = sys.exc_info()
if exc[0] is not None:
print(f" ⚠️ Pending exception detected: {exc}")
# Try to trigger the error
print(" Calling range(1)...")
for i in range(1):
pass
print(" ✓ range(1) worked")
print(" Creating Grid(grid_size=(25, 15))...")
grid = mcrfpy.Grid(grid_size=(25, 15))
check(True, "Grid(grid_size=(25, 15)) constructed")
check((grid.grid_size.x, grid.grid_size.y) == (25, 15),
f"grid_size is (25, 15), got ({grid.grid_size.x}, {grid.grid_size.y})")
no_stale_exception("Test 1")
except Exception as e:
print(f" ✗ Exception: {type(e).__name__}: {e}")
check(False, f"Grid(grid_size=(25, 15)) raised {type(e).__name__}: {e}")
print()
# Test 2: Try different Grid constructor patterns
print("Test 2: Different Grid constructor calls")
# Pattern 1: Positional arguments
try:
print(" Trying Grid(25, 15)...")
grid1 = mcrfpy.Grid(25, 15)
for i in range(1): pass
print(" ✓ Positional args worked")
except Exception as e:
print(f" ✗ Positional args failed: {e}")
# Pattern 2: Different size
try:
print(" Trying Grid(grid_w=24, grid_h=15)...")
grid2 = mcrfpy.Grid(grid_w=24, grid_h=15)
for i in range(1): pass
print(" ✓ Size 24x15 worked")
except Exception as e:
print(f" ✗ Size 24x15 failed: {e}")
# Pattern 3: Check if it's specifically 25
try:
print(" Trying Grid(grid_w=26, grid_h=15)...")
grid3 = mcrfpy.Grid(grid_w=26, grid_h=15)
for i in range(1): pass
print(" ✓ Size 26x15 worked")
except Exception as e:
print(f" ✗ Size 26x15 failed: {e}")
# Test 2: legacy grid_w/grid_h spelling still constructs the same grid
print("Test 2: Legacy grid_w=/grid_h= keywords")
for w, h in [(24, 15), (25, 15), (26, 15)]:
try:
g = mcrfpy.Grid(grid_w=w, grid_h=h)
ok = (g.grid_size.x, g.grid_size.y) == (w, h)
check(ok, f"Grid(grid_w={w}, grid_h={h}) -> grid_size ({g.grid_size.x}, {g.grid_size.y})")
no_stale_exception(f"Grid({w}, {h})")
except Exception as e:
check(False, f"Grid(grid_w={w}, grid_h={h}) raised {type(e).__name__}: {e}")
print()
# Test 3: Isolate the exact problem
print("Test 3: Isolating the problem")
# Test 3: Isolate the exact problem -- a sweep of sizes, each followed by a
# plain Python operation that would explode if an exception were left pending.
print("Test 3: Size sweep (construct, then immediately use the interpreter)")
def test_grid_creation(x, y):
"""Test creating a grid and immediately using range()"""
try:
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
# Immediately test if exception is pending
g = mcrfpy.Grid(grid_size=(x, y))
# Immediately test if an exception is pending
list(range(1))
if (g.grid_size.x, g.grid_size.y) != (x, y):
return False, f"wrong grid_size ({g.grid_size.x}, {g.grid_size.y})"
return True, "Success"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
# Test various sizes
test_sizes = [(10, 10), (20, 20), (24, 15), (25, 14), (25, 15), (25, 16), (30, 30)]
for x, y in test_sizes:
success, msg = test_grid_creation(x, y)
if success:
print(f" Grid({x}, {y}): ✓")
else:
print(f" Grid({x}, {y}): ✗ {msg}")
check(success, f"Grid({x}, {y}): {msg}")
print()
# Test 4: See if we can clear the exception
print("Test 4: Exception clearing")
# Test 4: bad arguments must raise properly (and not corrupt interpreter state)
print("Test 4: Invalid arguments raise instead of leaving a pending exception")
try:
# Create the problematic grid
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
print(" Created Grid(25, 15)")
# Try to clear any pending exception
try:
# This should fail if there's a pending exception
list(range(1))
print(" No pending exception!")
except:
print(" ⚠️ Pending exception detected")
# Clear it
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
# Try again
try:
list(range(1))
print(" ✓ Exception cleared, range() works now")
except:
print(" ✗ Exception persists")
bad = mcrfpy.Grid(grid_size="not a size")
check(False, "Grid(grid_size='not a size') should have raised")
except (TypeError, ValueError) as e:
check(True, f"Grid(grid_size='not a size') raised {type(e).__name__} as expected")
except Exception as e:
print(f" ✗ Failed: {e}")
check(False, f"Grid(grid_size='not a size') raised unexpected {type(e).__name__}: {e}")
no_stale_exception("Test 4")
print()
print("Conclusion: The Grid constructor is setting a Python exception")
print("but not properly returning NULL to propagate it. This leaves")
print("the exception on the stack, causing the next Python operation")
print("to fail with the cryptic 'new style getargs format' error.")
print("=" * 50)
if failures:
print(f"FAIL: {len(failures)} check(s) failed")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -7,7 +7,7 @@ print("Testing Grid features...")
# Create a texture first
print("Loading texture...")
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
print(f"Texture loaded: {texture}")
# Create grid
@ -54,5 +54,9 @@ if not hasattr(pos, 'x'):
print(f"pos.x={pos.x}, pos.y={pos.y}")
if pos.x != 50 or pos.y != 100:
print(f"FAIL: pos should be (50, 100), got ({pos.x}, {pos.y})")
sys.exit(1)
print("PASS: Grid Vector properties work correctly!")
sys.exit(0)

View file

@ -2,6 +2,17 @@
"""Test grid iteration patterns to find the exact cause"""
import mcrfpy
import sys
failures = []
def check(condition, label):
"""Record a failed check instead of silently printing a checkmark."""
if condition:
print(f" [ok] {label}")
else:
print(f" [FAIL] {label}")
failures.append(label)
print("Testing grid iteration patterns...")
print("=" * 50)
@ -11,21 +22,23 @@ print("Test 1: Basic grid.at() calls")
try:
test1 = mcrfpy.Scene("test1")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
# Single call
grid.at(0, 0).walkable = True
print("Single grid.at() call works")
check(grid.at(0, 0).walkable is True, "Single grid.at() call works")
# Multiple calls
grid.at(1, 1).walkable = True
grid.at(2, 2).walkable = True
print(" ✓ Multiple grid.at() calls work")
check(grid.at(1, 1).walkable and grid.at(2, 2).walkable,
"Multiple grid.at() calls work")
# Now try a print
print(" Print after grid.at() works")
print(" [ok] Print after grid.at() works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
failures.append(f"Test 1: {type(e).__name__}: {e}")
print()
@ -34,16 +47,18 @@ print("Test 2: Grid.at() in simple loop")
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
for i in range(3):
grid.at(i, 0).walkable = True
print(" ✓ Single loop with grid.at() works")
check(all(grid.at(i, 0).walkable for i in range(3)),
"Single loop with grid.at() works")
# Print after loop
print(" Print after loop works")
print(" [ok] Print after loop works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
failures.append(f"Test 2: {type(e).__name__}: {e}")
print()
@ -52,16 +67,18 @@ print("Test 3: Nested loops with grid.at()")
try:
test3 = mcrfpy.Scene("test3")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
for y in range(3):
for x in range(3):
grid.at(x, y).walkable = True
print(" ✓ Nested loops with grid.at() work")
print(" ✓ Print after nested loops works")
check(all(grid.at(x, y).walkable for y in range(3) for x in range(3)),
"Nested loops with grid.at() work")
print(" [ok] Print after nested loops works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
failures.append(f"Test 3: {type(e).__name__}: {e}")
print()
@ -71,38 +88,46 @@ try:
test4 = mcrfpy.Scene("test4")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Per-cell color moved off GridPoint onto ColorLayer (Grid layer rework);
# GridPoint now exposes only entities/grid_pos/transparent/walkable.
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# This is the exact nested loop from the failing code
for y in range(15):
for x in range(25):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
print(" ✓ Full nested loop completed")
# This is where it fails
colors.set((x, y), mcrfpy.Color(200, 200, 220))
check(all(grid.at(x, y).walkable and grid.at(x, y).transparent
for y in range(15) for x in range(25)),
"Full nested loop completed (375 cells set)")
# This is where it used to fail
print(" About to test post-loop operations...")
# Try different operations
x = 5
print(f"Variable assignment works: x={x}")
check(x == 5, f"Variable assignment works: x={x}")
lst = []
print(f"List creation works: {lst}")
check(lst == [], f"List creation works: {lst}")
# The failing line
for i in range(3): pass
print(" Empty for loop works")
print(" [ok] Empty for loop works")
# With append
for i in range(3): lst.append(i)
print(f"For loop with append works: {lst}")
check(lst == [0, 1, 2], f"For loop with append works: {lst}")
except Exception as e:
print(f" Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 4: {type(e).__name__}: {e}")
print()
@ -111,28 +136,40 @@ print("Test 5: Testing grid.at() call limits")
try:
test5 = mcrfpy.Scene("test5")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
count = 0
for y in range(10):
for x in range(10):
grid.at(x, y).walkable = True
count += 1
# Test print every 10 calls
if count % 10 == 0:
print(f" Processed {count} cells...")
print(f" ✓ Processed all {count} cells")
check(count == 100, f"Processed all {count} cells")
check(all(grid.at(x, y).walkable for y in range(10) for x in range(10)),
"All 100 cells still readable after the loop")
# Now test operations
print(" Testing post-processing operations...")
for i in range(3): pass
print(" All operations work after 100 grid.at() calls")
print(" [ok] All operations work after 100 grid.at() calls")
except Exception as e:
print(f" Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 5: {type(e).__name__}: {e}")
print()
print("Tests complete.")
print("Tests complete.")
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

Some files were not shown because too many files have changed in this diff Show more