Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions src/exit_dash/world/editing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
the same records the ``.dat`` format stores (see :mod:`exit_dash.world.level`), enforces
the format's fixed-slot limits, and round-trips through :mod:`exit_dash.world.loader`.

:meth:`EditorModel.playability` encodes the structural rules a level must satisfy to be
*built* by :func:`exit_dash.world.world.World.from_level_data` without crashing or
hanging — most importantly that there are enough platforms with distinct x-positions
that the mob-placement loop in ``generate_mobs`` can always terminate.
:meth:`EditorModel.playability` encodes the structural floor a level must meet to be
*built* by :func:`exit_dash.world.world.World.from_level_data` (at least two platforms,
since the build seeds its platform extremes from ``platforms[1]``). Mob placement is now
hang-proof in the engine itself, so the editor no longer needs to police platform
x-positions to keep ``generate_mobs`` terminating.
"""

from __future__ import annotations
Expand Down Expand Up @@ -150,14 +151,15 @@ def _in(rec: object, x: float, y: float, w: float, h: float) -> bool:
# -- validation & persistence -----------------------------------------------------

def playability(self) -> tuple[bool, str]:
"""Return ``(ok, reason)``. A non-ok level would crash/hang the level builder."""
if len(self.platforms) < 3:
return False, "need at least 3 platforms"
# generate_mobs() loops until it finds a non-spawn platform whose x differs from
# the longest platform's; that only terminates if the non-spawn platforms hold at
# least two distinct x-positions.
if len({p.x for p in self.platforms[1:]}) < 2:
return False, "non-spawn platforms need at least 2 distinct x-positions"
"""Return ``(ok, reason)``. A non-ok level would fail to build at all.

The only hard requirement is a spawn platform plus at least one more: the build
seeds its platform extremes from ``platforms[1]``. Mob placement is hang-proof in
the engine regardless of where the platforms sit, so there are no other structural
rules to enforce here — winnability is left to the level designer.
"""
if len(self.platforms) < 2:
return False, "need at least 2 platforms"
return True, "ready to play"

def save(self, path: Path) -> None:
Expand Down
16 changes: 16 additions & 0 deletions src/exit_dash/world/world.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,18 @@ def generate_mobs(self, longest: Platform, farthest: Platform) -> None:
quantity = len(self.platforms) - 1
sample = AICharacter(0, 0, 0, 0, ("slime", -1, -1))
self.mobs = []
# Ground mobs are placed on a random non-spawn platform that does not share the
# longest platform's x (the original avoided clustering them there). The rejection
# loop below re-rolls until it finds such a platform, so it only terminates if at
# least one exists; with none it would spin forever. Pre-checking eligibility makes
# generation provably terminating for any level data (e.g. a hand-built level whose
# non-spawn platforms all stack at one x) — we simply place no ground mobs instead.
# When eligible platforms exist this consumes RNG identically to the original, so
# procedurally-generated levels remain bit-for-bit reproducible.
eligible = any(p[0] != longest[0] for p in self.platforms[1:])
for _ in range(quantity):
if not eligible:
break
which = self.rng.randint(1, len(self.platforms) - 1)
while self.platforms[which][0] == longest[0]:
which = self.rng.randint(1, len(self.platforms) - 1)
Expand Down Expand Up @@ -240,6 +251,11 @@ def from_level_data(
background: Background | None = None,
hint: str = "",
) -> World:
# gather_platform_info() seeds its running extremes from platforms[1], so a level
# needs a spawn platform plus at least one more to build at all. Fail with a clear
# message rather than an opaque IndexError deep in the build.
if len(data.platforms) < 2:
raise ValueError("a level needs at least two platforms to build")
world = cls(
player,
screen_w,
Expand Down
15 changes: 7 additions & 8 deletions tests/test_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,22 @@ def test_round_trips_through_loader(tmp_path):


def test_too_few_platforms_is_unplayable():
model = EditorModel(
platforms=[PlatformRec(0, 0, 100), PlatformRec(0, 0, 100)],
door=DoorRec(0, 0),
)
# A lone spawn platform can't build: gather_platform_info() seeds from platforms[1].
model = EditorModel(platforms=[PlatformRec(0, 0, 100)], door=DoorRec(0, 0))
ok, reason = model.playability()
assert not ok
assert "platform" in reason


def test_identical_x_platforms_is_unplayable():
# All non-spawn platforms sharing an x would hang generate_mobs; must be rejected.
def test_identical_x_platforms_is_playable():
# All non-spawn platforms sharing an x used to hang generate_mobs; the engine now
# handles it (placing no ground mobs), so the editor no longer rejects such levels.
model = EditorModel(
platforms=[PlatformRec(0, 0, 100), PlatformRec(500, 100, 100), PlatformRec(500, 300, 100)],
door=DoorRec(0, 0),
)
ok, _ = model.playability()
assert not ok
assert ok


def test_editor_scene_places_and_can_test_play(pygame_ready):
Expand All @@ -99,7 +98,7 @@ def test_editor_scene_places_and_can_test_play(pygame_ready):

def test_custom_level_runs_headless():
# The full editor->play path: a level built from editor data boots and runs without
# crashing or hanging (the latter being the generate_mobs risk playability() guards).
# crashing. (generate_mobs is hang-proof in the engine; see test_world.py.)
data = EditorModel.blank().to_level_data()
app = Application(headless=True)
scene = LevelScene(
Expand Down
46 changes: 46 additions & 0 deletions tests/test_world.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from __future__ import annotations

import random
import threading

import pytest

from exit_dash.core.paths import asset_path
from exit_dash.entities.player import PlayableCharacter
from exit_dash.world import loader
from exit_dash.world.level import DoorRec, LevelData, PlatformRec
from exit_dash.world.world import World


Expand Down Expand Up @@ -62,3 +64,47 @@ def test_generated_level_is_reproducible_from_seed(player):
assert len(w1.blocks) == len(w2.blocks)
assert [e.mob_type for e in w1.enemies] == [e.mob_type for e in w2.enemies]
assert w1.door is not None and w1.keys


def test_degenerate_level_does_not_hang(player):
# Every non-spawn platform shares one x, so the longest platform is among them and the
# rejection loop in generate_mobs can never find a differently-placed platform. The old
# code spun forever here; the engine must now build the level (placing no ground mobs)
# instead of hanging. Built on a watchdog thread so a regression fails loudly rather
# than freezing the whole suite (pytest-timeout is not a dependency).
data = LevelData(
door=DoorRec(800, 400),
platforms=[
PlatformRec(-2000, 600, 300), # spawn platform, at a distinct x
PlatformRec(800, 600, 200),
PlatformRec(800, 400, 400), # widest non-spawn -> the "longest", at x=800
PlatformRec(800, 200, 300),
],
seed=1.0,
)
result: dict[str, World] = {}

def build() -> None:
result["world"] = World.from_level_data(
data, player=player, screen_w=1280, screen_h=720, rng=random.Random(1)
)

worker = threading.Thread(target=build, daemon=True)
worker.start()
worker.join(timeout=15.0)
assert not worker.is_alive(), (
"generate_mobs hung on a degenerate level (infinite-loop regression)"
)

world = result["world"]
# The level still builds and is winnable-shaped; only the patrol fly is placed, since
# no platform is eligible to host a ground mob.
assert [e.mob_type for e in world.enemies] == ["fly"]
assert world.door is not None and world.keys


def test_single_platform_level_raises(player):
# gather_platform_info() seeds its extremes from platforms[1]; one platform can't build.
data = LevelData(door=DoorRec(0, 0), platforms=[PlatformRec(0, 600, 300)])
with pytest.raises(ValueError, match="two platforms"):
World.from_level_data(data, player=player, screen_w=1280, screen_h=720)
Loading