From ca5641c61286a1afb49501312f151598848ef4c9 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 21 Jul 2026 07:46:42 -0700 Subject: [PATCH] fix(status): stop generic decay from racing the web struggle branch (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two systems decremented :webbed each turn: the movement struggle branches (world/player-move, ai/try-move), which trample the web on the last struggle, and the generic status tick, which removes an expired status silently. The ttl drained twice per turn and always expired inside the tick, so the trample branch never ran and every web survived its captive breaking free. PR #71 exposed this on the player path by softening the web ttl 3 -> 2 (at 3 the struggle branch happened to win the race); the monster path (tick before action in creature-turn) has never trampled since webs landed in 8feaa1c. Statuses can now declare :no-decay — the generic tick leaves their ttl to the owning system. :webbed's ttl becomes what the balance knob always looked like: the number of struggles to break free. Co-Authored-By: Claude Fable 5 --- xsofy/status.lg | 14 +++++++++++--- xsofy/test/run.lg | 1 + xsofy/test/status_test.lg | 40 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 xsofy/test/status_test.lg diff --git a/xsofy/status.lg b/xsofy/status.lg index 2013a259..adab3250 100644 --- a/xsofy/status.lg +++ b/xsofy/status.lg @@ -6,6 +6,8 @@ ;; :ttl — turns remaining ;; :dmg — damage per tick (optional) ;; :skip-turn — entity can't act (optional) +;; :no-decay — ttl is owned by a dedicated system; the generic tick +;; leaves it alone (optional) ;; :color — tint for rendering (def status-defs @@ -15,7 +17,11 @@ :confused {:color [200 100 255] :bg [40 20 50] :name "confused"} :slowed {:color [140 140 180] :bg [30 30 40] :name "slowed"} :haste {:color [255 255 100] :bg [40 40 15] :name "haste"} - :webbed {:color [200 200 210] :bg [30 30 32] :name "webbed"} + ;; :webbed ttl counts remaining struggles; the movement struggle branches + ;; (world/player-move, ai/try-move) decrement it and trample the web on + ;; the last one. Generic decay raced them and freed the creature with the + ;; web intact (#161), so the tick must not touch it. + :webbed {:color [200 200 210] :bg [30 30 32] :name "webbed" :no-decay true} :swimming {:color [60 100 180] :bg [10 20 50] :name "swimming"}}) ;; --- Apply / Remove --- @@ -51,8 +57,10 @@ world (reduce (fn [w [stype sdata]] - ;; Bail out if a prior tick (or external code) removed the entity. - (if (nil? (get-in w [:entities entity-id])) + ;; Bail out if a prior tick (or external code) removed the entity, + ;; or if the status decays through its own system (:no-decay). + (if (or (nil? (get-in w [:entities entity-id])) + (:no-decay (get status-defs stype))) w (let [def_ (get status-defs stype) ttl (dec (or (:ttl sdata) 1)) diff --git a/xsofy/test/run.lg b/xsofy/test/run.lg index 346b5c14..d5b0b171 100644 --- a/xsofy/test/run.lg +++ b/xsofy/test/run.lg @@ -15,6 +15,7 @@ [xsofy.test.dag-test] [xsofy.test.container-trigger-test] [xsofy.test.fire-test] + [xsofy.test.status-test] [xsofy.test.seed-test] [xsofy.test.mapgen-audit-test] [xsofy.test.replay-test] diff --git a/xsofy/test/status_test.lg b/xsofy/test/status_test.lg new file mode 100644 index 00000000..1cf14bf9 --- /dev/null +++ b/xsofy/test/status_test.lg @@ -0,0 +1,40 @@ +(ns xsofy.test.status-test + (:require [test :refer [deftest is testing]] + [xsofy.terrain :as terrain] + [xsofy.fire :as fire] + [xsofy.status :as status] + [xsofy.world :as world])) + +;; Regression tests for #161: webs no longer trampled. +;; +;; :webbed ttl is decremented by the movement struggle branches, which +;; trample the web on the last struggle. The generic status tick also +;; decremented it, so the ttl drained twice per turn and expired inside +;; the tick — which removes the status silently, without trampling. The +;; struggle branch then never saw ttl <= 1 and the web survived every +;; escape. :webbed is now :no-decay: the struggle branches own its ttl. + +(deftest generic-tick-leaves-webbed-alone + (testing ":no-decay statuses keep their ttl through the generic tick" + (let [w {:entities {:e {:id :e :pos [1 1]}}} + w (status/apply-status w :e :webbed 2) + w (status/apply-status w :e :confused 2) + w (status/tick-entity-statuses w :e)] + ;; still webbed, ttl untouched + (is (= 2 (get-in w [:entities :e :statuses :webbed :ttl]))) + ;; the normal decay path still works + (is (= 1 (get-in w [:entities :e :statuses :confused :ttl]))) + (let [w (status/tick-entity-statuses w :e)] + (is (status/has-status? w :e :webbed)) + (is (not (status/has-status? w :e :confused))))))) + +(deftest webbed-player-break-free-tramples-web + (testing "player struggling free of a web leaves ash, not an intact web" + (let [w (world/make-world 30 20 1) + [px py] (get-in w [:entities :player :pos]) + _ (terrain/tset! (:terrain w) (:width w) px py fire/web-tile) + w (status/apply-status w :player :webbed 2) + w (world/player-move w 1 0) ;; struggle: ttl 2 -> 1 + w (world/player-move w 1 0)] ;; final struggle: break free + trample + (is (not (status/has-status? w :player :webbed))) + (is (= fire/ash-tile (terrain/tget (:terrain w) (:width w) px py))))))