Spaces:
Sleeping
Sleeping
FORGIVING TOWNS: map any building kind (never reject), district reroute, restore god's voice (content-only moderation), town few-shot pushes build_district+roads, honest fail on empty
baf9d60 verified | """Tests for the GODSEED deterministic world engine (Agent A). | |
| Covers: clamping, unknown-enum/tool rejection, seed determinism, genesis | |
| rebuild equality, queue caps + crash isolation, moderation layers (clean | |
| pass, slur/leetspeak block, unicode-confusable block, hate symbol block, | |
| judge default-deny), and the <600-char world summary. | |
| """ | |
| import asyncio | |
| import json | |
| import sys | |
| import zlib | |
| from pathlib import Path | |
| import pytest | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| from engine.genesis import GENESIS_FEATURES, genesis_features | |
| from engine.moderation import Moderator | |
| from engine.queue_worker import ( | |
| QUEUE_MAX, | |
| AlreadyPending, | |
| QueueFull, | |
| QueueWorker, | |
| ) | |
| from engine.summary import GENESIS_MONOLITH, summarize, town_center, town_name | |
| from engine.tools import TOOL_NAMES, validate_call | |
| from engine.world import World | |
| def make_world() -> World: | |
| return World.load(genesis_features()) | |
| def ok_raise(lat=10, lon=10): | |
| return { | |
| "tool": "raise_terrain", | |
| "args": {"lat": lat, "lon": lon, "radius_deg": 5, "height": 0.05, "roughness": 0.5}, | |
| } | |
| # --------------------------------------------------------------------- clamping | |
| def test_clamp_out_of_range_numbers(): | |
| world = make_world() | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| { | |
| "tool": "raise_terrain", | |
| "args": {"lat": 200, "lon": -999, "radius_deg": 400, "height": 9, "roughness": -3}, | |
| }, | |
| ) | |
| assert obs == "ok: mountains risen at (85,-180)" | |
| assert feature.args == { | |
| "lat": 85.0, | |
| "lon": -180.0, | |
| "radius_deg": 45.0, | |
| "height": 0.12, | |
| "roughness": 0.0, | |
| } | |
| def test_clamp_moons_to_integer_and_enum_normalization(): | |
| world = make_world() | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "set_sky", "args": {"palette": " AURORA ", "star_density": -1, "moons": 9.7}}, | |
| ) | |
| assert obs == "ok: sky set to aurora" | |
| assert feature.args == {"palette": "aurora", "star_density": 0.0, "moons": 3} | |
| assert isinstance(feature.args["moons"], int) | |
| def test_inscribe_text_truncated_to_90(): | |
| world = make_world() | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "inscribe_wish", "args": {"text": "x" * 200, "style": "stone"}}, | |
| ) | |
| assert obs == "ok: wish inscribed in stone" | |
| assert feature.args["text"] == "x" * 90 | |
| def test_numeric_strings_accepted_and_clamped(): | |
| args, err = validate_call( | |
| "set_weather", {"kind": "rain", "intensity": "1.8"} | |
| ) | |
| assert err is None | |
| assert args == {"kind": "rain", "intensity": 1.0} | |
| def test_place_water_stream_and_pool(): | |
| # stream: hue clamped, 6 waypoints truncated to 5, no radius in canonical args | |
| args, err = validate_call( | |
| "place_water", | |
| {"kind": "stream", "path": [[0, 0], [10, 10], [20, 20], [30, 30], [40, 40], [50, 50]], | |
| "hue": 500, "radius_deg": 5}, | |
| ) | |
| assert err is None | |
| assert args["kind"] == "stream" | |
| assert len(args["path"]) == 5 | |
| assert args["hue"] == 260.0 | |
| assert "radius_deg" not in args | |
| # pool: path collapses to center, radius clamped | |
| args, err = validate_call( | |
| "place_water", {"kind": "pool", "path": [[95, 200], [1, 1]], "radius_deg": 99, "hue": 100} | |
| ) | |
| assert err is None | |
| assert args == {"kind": "pool", "path": [[85.0, 180.0]], "radius_deg": 10.0, "hue": 160.0} | |
| # stream with one waypoint is rejected | |
| args, err = validate_call("place_water", {"kind": "stream", "path": [[0, 0]], "hue": 200}) | |
| assert args is None and err == "rejected: stream needs 2..5 waypoints" | |
| # forgiving water (June 12 live traces — models fumble this tool the most): | |
| # pool without radius_deg gets the engine default | |
| args, err = validate_call("place_water", {"kind": "pool", "path": [[0, 0]], "hue": 200}) | |
| assert err is None and args["radius_deg"] == 5.0 | |
| # lat/lon (the shape of every other tool) synthesizes the path | |
| args, err = validate_call("place_water", {"kind": "pool", "lat": 10, "lon": 20, "hue": 200}) | |
| assert err is None and args["path"] == [[10.0, 20.0]] | |
| # a flat [lat, lon] pair is wrapped; hue defaults when missing | |
| args, err = validate_call("place_water", {"kind": "pool", "path": [12, 30]}) | |
| assert err is None and args["path"] == [[12.0, 30.0]] and args["hue"] == 190.0 | |
| # no location at all is still refused, with a teaching observation | |
| args, err = validate_call("place_water", {"kind": "pool"}) | |
| assert args is None and "lat+lon" in err | |
| # -------------------------------------------------------------------- rejection | |
| def test_unknown_tool_rejected(): | |
| world = make_world() | |
| before = world.version | |
| feature, obs = world.apply("w_000001", 0, {"tool": "summon_demon", "args": {}}) | |
| assert feature is None | |
| assert obs == "rejected: unknown tool 'summon_demon'" | |
| assert world.version == before # append-only list untouched on rejection | |
| def test_unknown_enum_rejected(): | |
| world = make_world() | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| { | |
| "tool": "spawn_flora", | |
| "args": {"lat": 0, "lon": 0, "radius_deg": 5, "kind": "cactus", "density": 1, "hue": 100}, | |
| }, | |
| ) | |
| assert feature is None | |
| assert obs == "rejected: unknown kind 'cactus'" | |
| def test_missing_arg_rejected(): | |
| world = make_world() | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "raise_terrain", "args": {"lat": 0, "lon": 0, "radius_deg": 5, "roughness": 0.5}}, | |
| ) | |
| assert feature is None | |
| assert obs == "rejected: missing arg 'height'" | |
| def test_bool_is_not_a_number(): | |
| args, err = validate_call("set_weather", {"kind": "rain", "intensity": True}) | |
| assert args is None and err == "rejected: bad arg 'intensity'" | |
| def test_rejected_call_does_not_consume_feature_id(): | |
| world = make_world() | |
| world.apply("w_000001", 0, {"tool": "nope", "args": {}}) | |
| feature, _ = world.apply("w_000001", 1, ok_raise()) | |
| assert feature.id == "f_000004" # genesis is f_000000..f_000003 | |
| # ------------------------------------------------------------------ determinism | |
| def test_seed_formula_matches_contract(): | |
| world = make_world() | |
| feature, _ = world.apply("w_000007", 3, ok_raise()) | |
| assert feature.seed == zlib.crc32(b"w_000007:3") & 0x7FFFFFFF | |
| def test_seed_deterministic_across_worlds(): | |
| f1, _ = make_world().apply("w_000005", 0, ok_raise()) | |
| f2, _ = make_world().apply("w_000005", 0, ok_raise()) | |
| assert f1.seed == f2.seed | |
| assert f1.args == f2.args | |
| assert f1.id == f2.id | |
| # ---------------------------------------------------------------------- genesis | |
| def test_genesis_rebuild_equality(): | |
| world = World.load(genesis_features()) | |
| rebuilt = world.state_dict()["features"] | |
| assert rebuilt == GENESIS_FEATURES | |
| # byte-for-byte through JSON, not just == (int/float and key fidelity) | |
| assert json.dumps(rebuilt, sort_keys=True) == json.dumps(GENESIS_FEATURES, sort_keys=True) | |
| assert world.epoch == 0 | |
| assert world.version == 4 | |
| assert world.sky["palette"] == "void" | |
| assert world.weather == {"kind": "clear", "intensity": 0.0} | |
| def test_genesis_features_returns_copies(): | |
| g = genesis_features() | |
| g[0]["args"]["moons"] = 99 | |
| assert GENESIS_FEATURES[0]["args"]["moons"] == 1 | |
| def test_sky_last_write_wins_but_features_append_only(): | |
| world = make_world() | |
| world.apply("w_000001", 0, {"tool": "set_sky", "args": {"palette": "gold", "star_density": 0.5, "moons": 2}}) | |
| state = world.state_dict() | |
| assert state["sky"]["palette"] == "gold" | |
| sky_features = [f for f in state["features"] if f["tool"] == "set_sky"] | |
| assert len(sky_features) == 2 # both writes kept for replay | |
| assert state["epoch"] == 1 | |
| # ------------------------------------------------------------------------ queue | |
| class GrantPlanner: | |
| """Mock planner: crashes on 'explode', otherwise raises terrain + tries a bad call.""" | |
| def __init__(self): | |
| self.granted = [] | |
| async def grant(self, wish, world_summary, act, emit): | |
| assert isinstance(world_summary, str) and len(world_summary) < 600 | |
| if "explode" in wish: | |
| raise RuntimeError("boom") | |
| await emit({"type": "thought_token", "text": "hm."}) | |
| obs = await act(ok_raise()) | |
| assert obs.startswith("ok:") | |
| bad = await act( | |
| {"tool": "spawn_flora", | |
| "args": {"lat": 0, "lon": 0, "radius_deg": 5, "kind": "cactus", "density": 1, "hue": 9}} | |
| ) | |
| assert bad == "rejected: unknown kind 'cactus'" | |
| self.granted.append(wish) | |
| return {"reading": "so be it", "turns": [], "epitaph": f"granted: {wish[:20]}"} | |
| async def _wait_for(events, type_, wish_id=None, timeout=5.0): | |
| loop = asyncio.get_running_loop() | |
| deadline = loop.time() + timeout | |
| while loop.time() < deadline: | |
| for event in events: | |
| if event.get("type") == type_ and (wish_id is None or event.get("wish_id") == wish_id): | |
| return event | |
| await asyncio.sleep(0.01) | |
| raise AssertionError(f"timeout waiting for {type_} ({wish_id})") | |
| def test_queue_caps(): | |
| async def run(): | |
| world = make_world() | |
| async def emit(event): | |
| pass | |
| worker = QueueWorker(world, Moderator(), GrantPlanner(), emit) # never started | |
| wish_id, position = await worker.submit("first wish", "client-a") | |
| assert wish_id == "w_000001" | |
| assert position == 1 | |
| with pytest.raises(AlreadyPending): | |
| await worker.submit("second wish, same soul", "client-a") | |
| for i in range(QUEUE_MAX - 1): | |
| await worker.submit(f"wish {i}", f"client-{i}") | |
| assert worker.queue_length == QUEUE_MAX | |
| with pytest.raises(QueueFull) as exc: | |
| await worker.submit("one too many", "client-z") | |
| assert "overwhelmed" in exc.value.reason | |
| asyncio.run(run()) | |
| def test_worker_crash_isolation_and_grant_flow(): | |
| async def run(): | |
| world = make_world() | |
| events, traces = [], [] | |
| async def emit(event): | |
| events.append(event) | |
| def persist(trace): | |
| traces.append(trace) | |
| planner = GrantPlanner() | |
| worker = QueueWorker(world, Moderator(), planner, emit, persist) | |
| await worker.start() | |
| try: | |
| crash_id, _ = await worker.submit("explode the heavens", "c1") | |
| grant_id, _ = await worker.submit("gentle green hills", "c2") | |
| rejected = await _wait_for(events, "wish_rejected", crash_id) | |
| assert rejected["reason"] # poetic generic, never a stack trace | |
| assert "boom" not in rejected["reason"] | |
| granted = await _wait_for(events, "wish_granted", grant_id) | |
| assert granted["epitaph"] == "granted: gentle green hills" | |
| assert granted["epoch"] == 1 | |
| # crash isolated: only the granted wish persisted, enriched by worker | |
| assert len(traces) == 1 | |
| assert traces[0]["wish_id"] == grant_id | |
| assert traces[0]["text"] == "gentle green hills" | |
| assert traces[0]["moderation"] == {"allowed": True, "category": None} | |
| assert traces[0]["submitted_at"] > 0 | |
| # tool_call + world_delta emitted with the contract seed | |
| delta = await _wait_for(events, "world_delta") | |
| assert delta["feature"]["seed"] == zlib.crc32(f"{grant_id}:0".encode()) & 0x7FFFFFFF | |
| call = await _wait_for(events, "tool_call", grant_id) | |
| assert call["call_index"] == 0 and call["tool"] == "raise_terrain" | |
| # client slot freed after completion (even after a crash) | |
| next_id, _ = await worker.submit("a quiet lighthouse", "c1") | |
| await _wait_for(events, "wish_granted", next_id) | |
| assert world.epoch == 2 | |
| assert planner.granted == ["gentle green hills", "a quiet lighthouse"] | |
| # moderation block: planner never called, poetic rejection emitted | |
| blocked_id, _ = await worker.submit("build a n1gg3r statue", "c3") | |
| blocked = await _wait_for(events, "wish_rejected", blocked_id) | |
| assert "declined" in blocked["reason"] | |
| assert len(planner.granted) == 2 | |
| # queue + wish_started events flowed | |
| assert any(e["type"] == "queue" and "length" in e for e in events) | |
| assert any(e["type"] == "wish_started" for e in events) | |
| finally: | |
| await worker.stop() | |
| assert not worker.is_running | |
| asyncio.run(run()) | |
| def test_wish_counter_resumes_from_persisted_world(): | |
| world = make_world() | |
| world.apply("w_000041", 0, ok_raise()) | |
| async def run(): | |
| async def emit(event): | |
| pass | |
| worker = QueueWorker(world, Moderator(), GrantPlanner(), emit) | |
| wish_id, _ = await worker.submit("continue the count", "c1") | |
| assert wish_id == "w_000042" | |
| asyncio.run(run()) | |
| # ------------------------------------------------------------------- moderation | |
| def test_moderation_clean_pass(): | |
| verdict = Moderator().precheck("a lighthouse on the northern shore, please") | |
| assert verdict.allowed | |
| assert verdict.category is None and verdict.poetic_reason is None | |
| def test_moderation_slur_leetspeak_block(): | |
| verdict = Moderator().precheck("build a n1gg3r statue") | |
| assert not verdict.allowed | |
| assert verdict.category == "hate" | |
| assert verdict.poetic_reason | |
| def test_moderation_unicode_confusable_block(): | |
| # Cyrillic і (U+0456) and е (U+0435) | |
| verdict = Moderator().precheck("make a nіggеr forest") | |
| assert not verdict.allowed | |
| assert verdict.category == "hate" | |
| def test_moderation_spaced_out_slur_block(): | |
| verdict = Moderator().precheck("a f.a.g.g.o.t shrine") | |
| assert not verdict.allowed | |
| assert verdict.category == "hate" | |
| def test_moderation_cyrillic_transliteration_block(): | |
| mod = Moderator() | |
| for wish in ("ХИТЛЕР shrine", "гитлер monument", "свастика lake"): | |
| verdict = mod.precheck(wish) | |
| assert not verdict.allowed and verdict.category == "hate", wish | |
| # benign Cyrillic still passes | |
| assert mod.precheck("снежные горы и тихое море").allowed | |
| def test_moderation_hate_symbol_block(): | |
| verdict = Moderator().precheck("plant a 卐 shaped forest") | |
| assert not verdict.allowed | |
| assert verdict.category == "hate" | |
| def test_moderation_violent_phrase_block(): | |
| verdict = Moderator().precheck("kill all the villagers") | |
| assert not verdict.allowed | |
| assert verdict.category == "violence" | |
| def test_moderation_no_scunthorpe_false_positives(): | |
| mod = Moderator() | |
| for wish in ( | |
| "a raccoon village under the analysis tower", | |
| "glass cliffs of sussex", | |
| "cucumber vines along the equator", | |
| "a peacock garden by the cockpit ruins", | |
| ): | |
| assert mod.precheck(wish).allowed, wish | |
| def test_moderation_length_and_charset(): | |
| mod = Moderator() | |
| assert mod.precheck("a" * 140).allowed | |
| assert mod.precheck("a" * 141).category == "length" | |
| assert mod.precheck("wish\x00wish").category == "charset" | |
| assert mod.precheck("").category == "empty" | |
| assert mod.precheck(" ").category == "empty" | |
| assert mod.precheck(None).category == "empty" | |
| # benign newlines fold to spaces rather than denying | |
| assert mod.precheck("two moons\nand a stream").allowed | |
| def test_moderation_judge_paths(): | |
| async def run(): | |
| calls = [] | |
| async def crashing_judge(text): | |
| raise RuntimeError("gpu fell over") | |
| async def garbage_judge(text): | |
| return "banana banana" | |
| async def allow_judge(text): | |
| calls.append(text) | |
| return {"allowed": True, "category": None} | |
| async def deny_judge(text): | |
| return '{"allowed": false, "category": "sad"}' | |
| async def fenced_judge(text): | |
| return '```json\n{"allowed": true}\n```' | |
| wish = "a calm violet sea" | |
| # default-deny on judge exception | |
| verdict = await Moderator(judge=crashing_judge).check(wish) | |
| assert not verdict.allowed and verdict.category == "uncertain" | |
| # default-deny on unparseable reply | |
| verdict = await Moderator(judge=garbage_judge).check(wish) | |
| assert not verdict.allowed and verdict.category == "uncertain" | |
| # judge allow passes through | |
| verdict = await Moderator(judge=allow_judge).check(wish) | |
| assert verdict.allowed and calls == [wish] | |
| # judge deny is honored, category preserved, reason poetic | |
| verdict = await Moderator(judge=deny_judge).check(wish) | |
| assert not verdict.allowed and verdict.category == "sad" and verdict.poetic_reason | |
| # tolerant of code fences around valid JSON | |
| verdict = await Moderator(judge=fenced_judge).check(wish) | |
| assert verdict.allowed | |
| # no judge configured: layers 1-2 decide | |
| verdict = await Moderator().check(wish) | |
| assert verdict.allowed | |
| asyncio.run(run()) | |
| def test_moderation_judge_skipped_when_blocklisted(): | |
| async def run(): | |
| calls = [] | |
| async def judge(text): | |
| calls.append(text) | |
| return {"allowed": True} | |
| verdict = await Moderator(judge=judge).check("a n1gger forest") | |
| assert not verdict.allowed | |
| assert calls == [] # wordlist layer denies before the judge spends tokens | |
| asyncio.run(run()) | |
| # ---------------------------------------------------------------------- summary | |
| def test_summary_compact_and_complete(): | |
| world = make_world() | |
| wish = "w_000001" | |
| world.apply(wish, 0, ok_raise(22, -40)) | |
| world.apply(wish, 1, {"tool": "spawn_flora", "args": {"lat": 22, "lon": -40, "radius_deg": 8, "kind": "glowgrass", "density": 0.7, "hue": 160}}) | |
| world.apply(wish, 2, {"tool": "place_structure", "args": {"lat": 5, "lon": 5, "kind": "lighthouse", "scale": 1.0, "hue": 40}}) | |
| world.apply(wish, 3, {"tool": "set_weather", "args": {"kind": "mist", "intensity": 0.4}}) | |
| world.record_epitaph("the hills learned to glow") | |
| world.record_epitaph("a lighthouse for the lost") | |
| text = world.summary() | |
| assert len(text) < 600 | |
| assert "epoch 1" in text | |
| assert "glowgrass" in text | |
| assert "lighthouse(5,5)" in text | |
| assert "mist" in text | |
| assert "a lighthouse for the lost" in text | |
| assert summarize(world) == text | |
| def test_summary_stays_under_cap_with_many_features(): | |
| world = make_world() | |
| for i in range(40): | |
| wish = f"w_{i + 1:06d}" | |
| world.apply(wish, 0, ok_raise((i * 7) % 80 - 40, (i * 13) % 300 - 150)) | |
| world.apply(wish, 1, {"tool": "place_structure", "args": {"lat": i % 60, "lon": -i % 120, "kind": "shrine", "scale": 1.2, "hue": i * 8 % 360}}) | |
| world.apply(wish, 2, {"tool": "spawn_flora", "args": {"lat": 0, "lon": 0, "radius_deg": 10, "kind": ["trees", "vines", "reeds", "flowers", "mushrooms", "glowgrass"][i % 6], "density": 0.5, "hue": 100}}) | |
| world.record_epitaph("an unreasonably long epitaph about the slow work of small gods " + "x" * 60) | |
| assert len(world.summary()) < 600 | |
| # ------------------------------------------------------------------------ misc | |
| def test_tool_surface_is_exactly_eleven(): | |
| assert TOOL_NAMES == ( | |
| "raise_terrain", | |
| "lower_terrain", | |
| "spawn_flora", | |
| "place_structure", | |
| "place_water", | |
| "set_weather", | |
| "set_sky", | |
| "inscribe_wish", | |
| "spawn_life", | |
| "build_district", | |
| "place_road", | |
| ) | |
| # --------------------------------------------------------------------- city update | |
| def test_new_structure_kinds_accepted(): | |
| world = make_world() | |
| for kind in ("tower", "warehouse", "cafe"): | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "place_structure", | |
| "args": {"lat": 5, "lon": 5, "kind": kind, "scale": 1.0, "hue": 40}}, | |
| ) | |
| assert feature is not None, kind | |
| assert feature.args["kind"] == kind | |
| assert obs == f"ok: {kind} placed at (5,5)" | |
| def test_place_structure_forgives_unknown_and_synonym_kinds(): | |
| # June 12: rejecting model-invented kinds made town wishes build nothing. | |
| # Now place_structure NEVER rejects a kind — it maps it. | |
| args, err = validate_call( | |
| "place_structure", {"lat": 0, "lon": 0, "kind": "castle", "scale": 1.0, "hue": 100}) | |
| assert err is None and args["kind"] == "house" # unknown -> humble house | |
| for given, expected in [("market_square", "market"), ("skyscraper", "tower"), | |
| ("coffeehouse", "cafe"), ("cottage", "house"), ("church", "shrine")]: | |
| args, err = validate_call( | |
| "place_structure", {"lat": 1, "lon": 1, "kind": given, "scale": 1.0, "hue": 50}) | |
| assert err is None and args["kind"] == expected, f"{given} -> {args}" | |
| # a "district"-ish kind reroutes to a dense build_district | |
| args, err = validate_call( | |
| "place_structure", {"lat": 2, "lon": 3, "kind": "neighborhood", "scale": 1.0, "hue": 40}) | |
| assert err is None and "density" in args and "kind" not in args # build_district shape | |
| def test_spawn_life_valid_and_clamped(): | |
| world = make_world() | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "spawn_life", | |
| "args": {"lat": 200, "lon": -999, "radius_deg": 99, "kind": "CARTS", | |
| "count": 50, "hue": 720}}, | |
| ) | |
| assert feature is not None | |
| assert feature.args == { | |
| "lat": 85.0, | |
| "lon": -180.0, | |
| "radius_deg": 20.0, # clamped to spawn_life max | |
| "kind": "carts", # enum lowercased | |
| "count": 12, # clamped to max, integer | |
| "hue": 360.0, | |
| } | |
| assert isinstance(feature.args["count"], int) | |
| assert obs == "ok: 12 carts stirring at (85,-180)" | |
| def test_spawn_life_count_is_integer_and_low_clamp(): | |
| args, err = validate_call( | |
| "spawn_life", | |
| {"lat": 0, "lon": 0, "radius_deg": 0.1, "kind": "fireflies", | |
| "count": 0.4, "hue": 200}, | |
| ) | |
| assert err is None | |
| assert args["radius_deg"] == 1.0 # clamped up to min | |
| assert args["count"] == 1 # rounds/clamps up to min, integer | |
| assert isinstance(args["count"], int) | |
| assert args["kind"] == "fireflies" | |
| def test_spawn_life_rejects_unknown_kind(): | |
| args, err = validate_call( | |
| "spawn_life", | |
| {"lat": 0, "lon": 0, "radius_deg": 5, "kind": "dragons", "count": 3, "hue": 100}, | |
| ) | |
| assert args is None and err == "rejected: unknown kind 'dragons'" | |
| def test_spawn_life_missing_arg_rejected(): | |
| args, err = validate_call( | |
| "spawn_life", | |
| {"lat": 0, "lon": 0, "radius_deg": 5, "kind": "birds", "hue": 100}, | |
| ) | |
| assert args is None and err == "rejected: missing arg 'count'" | |
| # --------------------------------------------------------------------- town mode | |
| def test_build_district_valid_and_clamped(): | |
| world = make_world() | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "build_district", | |
| "args": {"lat": 14, "lon": 38, "radius_deg": 99, "density": 9, "hue": 720}}, | |
| ) | |
| assert feature is not None | |
| assert feature.args == { | |
| "lat": 14.0, | |
| "lon": 38.0, | |
| "radius_deg": 15.0, # clamped to build_district max | |
| "density": 1.0, # clamped to max | |
| "hue": 360.0, | |
| } | |
| assert obs == "ok: a district rises near (14,38)" | |
| def test_build_district_radius_clamps_up_and_missing_arg_rejected(): | |
| args, err = validate_call( | |
| "build_district", | |
| {"lat": 0, "lon": 0, "radius_deg": 0.5, "density": 0.4, "hue": 200}, | |
| ) | |
| assert err is None | |
| assert args["radius_deg"] == 2.0 # clamped up to min | |
| args, err = validate_call( | |
| "build_district", {"lat": 0, "lon": 0, "density": 0.4, "hue": 200} | |
| ) | |
| assert args is None and err == "rejected: missing arg 'radius_deg'" | |
| def test_place_road_valid_and_clamped(): | |
| world = make_world() | |
| # 7 waypoints truncated to 6; out-of-range coords clamped | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "place_road", | |
| "args": {"path": [[200, -999], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6]]}}, | |
| ) | |
| assert feature is not None | |
| assert len(feature.args["path"]) == 6 | |
| assert feature.args["path"][0] == [85.0, -180.0] | |
| assert feature.args == {"path": [[85.0, -180.0], [1.0, 1.0], [2.0, 2.0], | |
| [3.0, 3.0], [4.0, 4.0], [5.0, 5.0]]} | |
| assert obs == "ok: a road laid through 6 points" | |
| def test_place_road_flat_pair_and_dict_points(): | |
| # a flat [lat, lon] pair is NOT enough — a road needs two waypoints | |
| args, err = validate_call("place_road", {"path": [12, 30]}) | |
| assert args is None and err == "rejected: road needs 2..6 waypoints" | |
| # dict waypoints are accepted (like place_water) | |
| args, err = validate_call( | |
| "place_road", {"path": [{"lat": 10, "lon": 20}, {"lat": 12, "lon": 22}]} | |
| ) | |
| assert err is None and args == {"path": [[10.0, 20.0], [12.0, 22.0]]} | |
| def test_place_road_rejects_missing_and_short_path(): | |
| args, err = validate_call("place_road", {}) | |
| assert args is None and "road needs path" in err | |
| args, err = validate_call("place_road", {"path": [[10, 20]]}) | |
| assert args is None and err == "rejected: road needs 2..6 waypoints" | |
| args, err = validate_call("place_road", {"path": [[10, 20], ["x", "y"]]}) | |
| assert args is None and "bad waypoint" in err | |
| def test_new_civic_structure_kinds_accepted(): | |
| world = make_world() | |
| for kind in ("bank", "market", "house"): | |
| feature, obs = world.apply( | |
| "w_000001", | |
| 0, | |
| {"tool": "place_structure", | |
| "args": {"lat": 5, "lon": 5, "kind": kind, "scale": 1.0, "hue": 40}}, | |
| ) | |
| assert feature is not None, kind | |
| assert feature.args["kind"] == kind | |
| assert obs == f"ok: {kind} placed at (5,5)" | |
| def test_town_center_genesis_fallback(): | |
| # A fresh genesis world has no non-genesis town tools -> monolith fallback. | |
| world = make_world() | |
| assert town_center(world) == GENESIS_MONOLITH == (14.0, 38.0) | |
| # Terrain / flora alone do NOT anchor the town (only structures + districts). | |
| world.apply("w_000001", 0, ok_raise(50, -50)) | |
| world.apply("w_000001", 1, {"tool": "spawn_flora", | |
| "args": {"lat": 60, "lon": 60, "radius_deg": 8, "kind": "trees", | |
| "density": 0.5, "hue": 120}}) | |
| assert town_center(world) == GENESIS_MONOLITH | |
| def test_town_center_unit_vector_centroid(): | |
| world = make_world() | |
| world.apply("w_000001", 0, {"tool": "place_structure", | |
| "args": {"lat": 10, "lon": 20, "kind": "tower", "scale": 1.0, "hue": 200}}) | |
| world.apply("w_000001", 1, {"tool": "build_district", | |
| "args": {"lat": 20, "lon": 40, "radius_deg": 8, "density": 0.6, "hue": 40}}) | |
| # Unit-vector mean of (10,20) and (20,40), renormalized (computed offline). | |
| assert town_center(world) == (15.2206, 29.7632) | |
| def test_town_center_excludes_genesis_monolith(): | |
| # The genesis monolith (14,38) is NOT counted; one built tower defines the town. | |
| world = make_world() | |
| world.apply("w_000001", 0, {"tool": "place_structure", | |
| "args": {"lat": -30, "lon": 100, "kind": "house", "scale": 1.0, "hue": 30}}) | |
| lat, lon = town_center(world) | |
| assert round(lat) == -30 and round(lon) == 100 | |
| def test_town_name_is_deterministic(): | |
| # Same rounded center -> same name; from the curated pool. | |
| from engine.summary import TOWN_NAMES | |
| n1 = town_name(14.0, 38.0) | |
| n2 = town_name(14.49, 37.51) # rounds to the same (14, 38) | |
| assert n1 == n2 == "Emberlyn" | |
| assert n1 in TOWN_NAMES | |
| # The genesis-fallback town is always named the same. | |
| assert town_name(*GENESIS_MONOLITH) == "Emberlyn" | |
| def test_summary_names_the_town_and_tally(): | |
| world = make_world() | |
| wish = "w_000001" | |
| world.apply(wish, 0, {"tool": "place_structure", | |
| "args": {"lat": 14, "lon": 38, "kind": "tower", "scale": 1.4, "hue": 200}}) | |
| world.apply(wish, 1, {"tool": "place_structure", | |
| "args": {"lat": 15, "lon": 39, "kind": "tower", "scale": 1.4, "hue": 200}}) | |
| world.apply(wish, 2, {"tool": "place_structure", | |
| "args": {"lat": 13, "lon": 37, "kind": "cafe", "scale": 0.9, "hue": 40}}) | |
| world.apply(wish, 3, {"tool": "build_district", | |
| "args": {"lat": 14, "lon": 38, "radius_deg": 8, "density": 0.7, "hue": 30}}) | |
| text = world.summary() | |
| assert len(text) < 600 | |
| assert "The town of" in text | |
| assert "stands near" in text | |
| assert "2 towers" in text | |
| assert "a cafe" in text | |
| assert "a district" in text | |
| # The town line leads the summary (steering: where to keep building). | |
| assert text.startswith("The town of") | |
| def test_summary_town_line_survives_truncation(): | |
| world = make_world() | |
| for i in range(40): | |
| wish = f"w_{i + 1:06d}" | |
| world.apply(wish, 0, {"tool": "place_structure", | |
| "args": {"lat": i % 60 - 30, "lon": (i * 9) % 300 - 150, | |
| "kind": "house", "scale": 1.0, "hue": i * 8 % 360}}) | |
| world.record_epitaph("a long epitaph about the slow work of small gods " + "x" * 60) | |
| text = world.summary() | |
| assert len(text) < 600 | |
| assert text.startswith("The town of") | |