diff --git a/xsofy/fire.lg b/xsofy/fire.lg index d52863f..70953ff 100644 --- a/xsofy/fire.lg +++ b/xsofy/fire.lg @@ -18,6 +18,26 @@ (def fire-spread-chance 30) ;; percent per adjacent flammable per tick (def fire-default-ttl 5) (def protected-tiles #{13 14}) ;; stairs must survive dragon fire and rune fire +(def water-tiles #{3 4}) ;; shallow + deep — extinguishes fire, never ignites + +(defn water-tile? [tile] + (contains? water-tiles tile)) + +(defn on-water? + "True if the entity's current tile is shallow or deep water." + [world entity-id] + (if-let [[x y] (get-in world [:entities entity-id :pos])] + (water-tile? (terrain/tget (:terrain world) (:width world) x y)) + false)) + +(defn apply-burning + "Apply :burning unless the entity is standing in water (#162). + Move-time extinguish already handles walking into water; this closes the + standing-still path (e.g. fire-imp shot while already in water)." + [world entity-id ttl] + (if (on-water? world entity-id) + world + (status/apply-status world entity-id :burning ttl))) ;; Fire is light-emitting, so any ignition must invalidate the :lights ;; cache — step-fire's own terrain-changed gate only sees fires it spreads @@ -36,17 +56,20 @@ world))) (defn ignite-forced [world x y ttl] - "Force fire at a position regardless of terrain." + "Force fire at a position regardless of terrain. + Water is the exception: it never ignites and never gains fire-ttl (#162)." (let [{:keys [terrain width]} world - tile (terrain/tget terrain width x y) - placed? (and (not= tile 0) (not= tile 8) (not= tile 7) - (not (contains? protected-tiles tile)))] ;; not void, wall, lava, stairs - (when placed? - (terrain/tset! terrain width x y fire-tile)) - (if (contains? protected-tiles tile) + tile (terrain/tget terrain width x y)] + (if (water-tile? tile) world - (let [w (assoc-in world [:fire-ttl [x y]] ttl)] - (if placed? (assoc w :lights nil) w))))) + (let [placed? (and (not= tile 0) (not= tile 8) (not= tile 7) + (not (contains? protected-tiles tile)))] ;; not void, wall, lava, stairs + (when placed? + (terrain/tset! terrain width x y fire-tile)) + (if (contains? protected-tiles tile) + world + (let [w (assoc-in world [:fire-ttl [x y]] ttl)] + (if placed? (assoc w :lights nil) w))))))) (defn step-fire [world] "One tick of fire simulation. Spread, damage, burn out. @@ -118,7 +141,7 @@ (ent/damage-entity w (:id e) fire-damage))] ;; only apply burning if still alive (if (get-in w [:entities (:id e)]) - (status/apply-status w (:id e) :burning 10) + (apply-burning w (:id e) 10) w))) w targets))) w @new-ttl)] diff --git a/xsofy/spell.lg b/xsofy/spell.lg index f1f280b..0856f1c 100644 --- a/xsofy/spell.lg +++ b/xsofy/spell.lg @@ -179,7 +179,7 @@ pos nil)) (fn [[world] pos e] (let [world (fire/ignite-forced world (first pos) (second pos) ttl)] - [(status/apply-status world (:id e) :burning burn-ttl)])))] + [(fire/apply-burning world (:id e) burn-ttl)])))] (assoc ctx :world w)))) (defn r-ice diff --git a/xsofy/test/fire_test.lg b/xsofy/test/fire_test.lg index 1164055..2dd65f7 100644 --- a/xsofy/test/fire_test.lg +++ b/xsofy/test/fire_test.lg @@ -1,7 +1,9 @@ (ns xsofy.test.fire-test (:require [test :refer [deftest is testing]] [xsofy.terrain :as terrain] - [xsofy.fire :as fire])) + [xsofy.status :as status] + [xsofy.fire :as fire] + [xsofy.spell :as spell])) (defn- room-world [] (let [w 12 h 8 @@ -10,7 +12,7 @@ {:terrain t :width w :height h :fire-ttl {}})) ;; Regression for the :lights cache gate (perf: only invalidate on real -;; terrain mutation). step-fire's terrain-changed gate only sees fires it +;; terrain mutation). step-fire's own terrain-changed gate only sees fires it ;; spreads or burns out — ignitions from burning-entities-ignite / dragon / ;; rune fire run in separate phases, so the ignite helpers must invalidate ;; :lights themselves or a new fire emits no light until it spreads. @@ -35,3 +37,44 @@ _ (terrain/tset! (:terrain w) (:width w) 3 3 8)] ;; wall — not flammable (let [w' (fire/ignite w 3 3)] (is (= [] (:lights w')))))) + +;; --- #162: water prevents burning / forced ignition --- + +(deftest ignite-forced-skips-water + (testing "shallow and deep water never become fire" + (doseq [tile [3 4]] + (let [w (assoc (room-world) :lights []) + _ (terrain/tset! (:terrain w) (:width w) 3 3 tile) + w' (fire/ignite-forced w 3 3 5)] + (is (= tile (terrain/tget (:terrain w') (:width w') 3 3))) + (is (nil? (get-in w' [:fire-ttl [3 3]]))) + (is (= [] (:lights w'))))))) + +(deftest apply-burning-skipped-on-water + (testing "entity standing in water does not catch fire" + (doseq [tile [3 4]] + (let [w (-> (room-world) + (assoc :entities {:e {:id :e :pos [3 3] :body {:hp 10}}})) + _ (terrain/tset! (:terrain w) (:width w) 3 3 tile) + w' (fire/apply-burning w :e 12)] + (is (not (status/has-status? w' :e :burning)))))) + (testing "entity on floor still burns" + (let [w (-> (room-world) + (assoc :entities {:e {:id :e :pos [3 3] :body {:hp 10}}})) + w' (fire/apply-burning w :e 12)] + (is (status/has-status? w' :e :burning))))) + +(deftest fire-imp-spell-on-water-does-not-burn + (testing "an imp's fire rune leaves a player in water unburned" + (doseq [tile [3 4]] + (let [w (-> (room-world) + (assoc :entities {:player {:id :player :pos [3 3] + :body {:hp 20 :max-hp 20}} + :imp {:id :imp :pos [2 3] + :body {:hp 8 :max-hp 8}}})) + _ (terrain/tset! (:terrain w) (:width w) 3 3 tile) + result (spell/eval-spell w :imp [3 3] [:fire]) + w' (:world result)] + (is (= tile (terrain/tget (:terrain w') (:width w') 3 3))) + (is (nil? (get-in w' [:fire-ttl [3 3]]))) + (is (not (status/has-status? w' :player :burning))))))) diff --git a/xsofy/world.lg b/xsofy/world.lg index a99108e..8bcd292 100644 --- a/xsofy/world.lg +++ b/xsofy/world.lg @@ -712,7 +712,7 @@ (opctx/with-context {:source :lava :reason :hazard} (ent/damage-entity world entity-id 8)) (#(if (get-in % [:entities entity-id]) - (status/apply-status % entity-id :burning 12) + (fire/apply-burning % entity-id 12) %)))) ;; chasm: 10 fall damage 6 (opctx/with-context {:source :chasm :reason :hazard}