From 901ab550120cb388e929e4fe09ac907e1d6b64ec Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:17:06 -0700 Subject: [PATCH] feat: extend gridmind --- .github/workflows/ci.yml | 38 +++++++++ CHANGELOG.md | 16 ++++ LICENSE | 21 +++++ README.md | 158 +++++++++++++++++++++++++++++++++++ ROADMAP.md | 32 +++++++ dev/gridmind/demo.clj | 40 +++++++++ src/gridmind/agent.clj | 127 ++++++++++++++++++++++++++++ src/gridmind/world.clj | 4 +- src/gridmind/worlds.clj | 1 + test/gridmind/agent_test.clj | 111 ++++++++++++++++++++++++ test/gridmind/run_tests.clj | 15 ++++ test/gridmind/world_test.clj | 46 ++++++++++ 12 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 dev/gridmind/demo.clj create mode 100644 src/gridmind/agent.clj create mode 100644 test/gridmind/agent_test.clj create mode 100644 test/gridmind/run_tests.clj create mode 100644 test/gridmind/world_test.clj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f49b857 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: clojure tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Clojure CLI + uses: DeLaGuardo/setup-clojure@12 + with: + cli: '1.11.1' + + - name: Cache Clojure dependencies + uses: actions/cache@v4 + with: + path: | + ~/.m2/repository + .cpcache + key: ${{ runner.os }}-deps-${{ hashFiles('deps.edn') }} + restore-keys: | + ${{ runner.os }}-deps- + + - name: Run tests + run: clojure -M:test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e0093a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes appear in this file. +The format follows Keep a Changelog conventions. + +## 0.1.0 - unreleased + +### Added + +- Tabular Q-learning and SARSA agents +- Epsilon-greedy exploration with per-episode decay +- Seeded training for reproducible results +- Deterministic test suite for agents and worlds +- Demo runner for the built-in worlds +- CI workflow for the test suite +- README, roadmap, and license diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..10c2fe5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gridmind contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6597780 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Gridmind + +![CI](https://github.com/DanielCuevas1208/gridmind/actions/workflows/ci.yml/badge.svg) + +Gridmind is a Clojure library that learns to solve grid worlds. +It trains agents with tabular Q-learning and SARSA. +The only dependency is the Clojure language itself. + +## Highlights + +- Deterministic grid world model +- Tabular Q-learning and SARSA +- Seeded, reproducible training runs +- Built-in worlds: small, windy, and cliff +- No runtime dependencies beyond Clojure + +## Quick start + +Add Gridmind to your deps.edn as a git dependency. + +```clojure +{:deps {gridmind/gridmind {:git/url "https://github.com/DanielCuevas1208/gridmind" + :sha ""}}} +``` + +Train an agent and read its policy. + +```clojure +(require '[gridmind.worlds :as worlds] + '[gridmind.agent :as agent]) + +(let [{:keys [q-table rewards]} + (agent/train worlds/small {:episodes 200 :seed 0})] + (agent/evaluate worlds/small q-table)) +;; => [0 3] +``` + +The agent learns to reach the goal. + +## Architecture + +Gridmind has three source namespaces. + +- `gridmind.world` defines the deterministic grid world model. +- `gridmind.worlds` loads the built-in worlds from EDN resources. +- `gridmind.agent` implements the learning algorithms. + +The world model returns the next state, the reward, and a done flag. +The agent stores one value per state-action pair in a Q-table. +A high value means the action moves the agent toward the goal. + +## Learning algorithms + +### Q-learning + +Q-learning is an off-policy algorithm. +The agent updates the value of the action it took. +It uses the best action at the next state for the target. +Set `:algorithm :q-learning` to use it. This is the default. + +### SARSA + +SARSA is an on-policy algorithm. +The agent updates with the action it actually takes next. +Set `:algorithm :sarsa` to use it. + +### Epsilon-greedy exploration + +The agent picks random actions with probability `:epsilon`. +This explores the world during early training. +Set `:epsilon-decay` below 1.0 to reduce randomness over time. + +## API + +| Function | Purpose | +| --- | --- | +| `gridmind.agent/q-table` | Create an empty Q-table. | +| `gridmind.agent/q-value` | Read a state-action value. | +| `gridmind.agent/update-q!` | Update one state-action value. | +| `gridmind.agent/greedy-action` | Pick the best action for a state. | +| `gridmind.agent/epsilon-greedy` | Pick an action with exploration. | +| `gridmind.agent/run-episode` | Run one learning episode. | +| `gridmind.agent/train` | Train across many episodes. | +| `gridmind.agent/greedy-policy` | Build a policy from a Q-table. | +| `gridmind.agent/evaluate` | Walk the greedy policy to a terminal state. | + +### Training options + +| Option | Default | Meaning | +| --- | --- | --- | +| `:episodes` | 100 | Number of episodes to train. | +| `:alpha` | 0.5 | Learning rate. | +| `:gamma` | 1.0 | Discount factor. | +| `:epsilon` | 0.1 | Exploration probability. | +| `:epsilon-decay` | 1.0 | Multiplier for epsilon per episode. | +| `:seed` | 0 | Seed for the random source. | +| `:algorithm` | `:q-learning` | `:q-learning` or `:sarsa`. | + +## Built-in worlds + +| World | Description | +| --- | --- | +| `small` | A 3 by 4 grid with a goal and a hazard. | +| `windy` | The classic windy grid world. | +| `cliff` | The cliff walking world. | + +Load any built-in world by name with `gridmind.worlds/load`. + +## Sample output + +Run the demo to see training curves and learned paths. + +``` +== small == + reward, first episode: -1.090 + reward, last episode: 0.980 + greedy path: [[0 0] [0 1] [0 2] [0 3]] -> goal +== windy == + reward, first episode: -1000.000 + reward, last episode: -15.000 + greedy path: [[3 0] [3 1] [3 2] [2 3] [1 4] [0 5] [0 6] [0 7] + [0 8] [0 9] [1 9] [2 9] [3 9] [4 9] [5 9] [6 9] + [5 8] [3 7]] -> goal +== cliff == + reward, first episode: -112.000 + reward, last episode: -11.000 + greedy path: [[3 0] [2 0] [2 1] [2 2] [2 3] [2 4] [2 5] [2 6] + [2 7] [2 8] [2 9] [2 10] [2 11] [3 11]] -> goal +``` + +Each world ends at the goal after training. + +## Limitations + +- Tabular methods only suit small state spaces. +- Transitions are deterministic. +- The library does not scale to large grids. +- Training reward depends on the chosen seed. + +## Test status + +The suite has 24 tests and 49 assertions. +Every test is deterministic and needs no network. +Run it with the command below. + +``` +clojure -M:test +``` + +The CI workflow runs the same suite on every push. + +## Roadmap + +See ROADMAP.md for what is done and what remains. + +## License + +Gridmind is released under the MIT License. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..926740e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,32 @@ +# Roadmap + +This file tracks the direction of Gridmind. +It shows what is complete and what remains. + +## Complete + +- Deterministic grid world model +- EDN world definitions and validation +- Built-in worlds: small, windy, and cliff +- Tabular Q-learning +- SARSA +- Epsilon-greedy exploration with decay +- Seeded, reproducible training +- Demo and test suite +- CI workflow and project documentation + +## In progress + +Nothing is in progress right now. + +## Next + +- Value iteration and policy iteration +- Function approximation for large grids +- Stochastic transitions +- Export Q-tables to EDN +- Publish to Clojars + +## Completed in detail + +- 0.1.0: learning agents. See CHANGELOG.md. diff --git a/dev/gridmind/demo.clj b/dev/gridmind/demo.clj new file mode 100644 index 0000000..9fa244e --- /dev/null +++ b/dev/gridmind/demo.clj @@ -0,0 +1,40 @@ +(ns gridmind.demo + "Train an agent on each built-in world. + Prints the reward curve and the final greedy path." + + (:require [gridmind.agent :as agent] + [gridmind.world :as world] + [gridmind.worlds :as worlds])) + +(defn- greedy-path + "Return the positions visited by the greedy policy, up to a terminal state." + [world q] + (let [policy (agent/greedy-policy q)] + (loop [pos (:start world) + seen []] + (if (or (world/terminal? world pos) (some #{pos} seen)) + (conj seen pos) + (recur (:state (world/step world pos (policy pos))) + (conj seen pos)))))) + +(defn- train-and-report + "Train on `world` and print one line per metric." + [world options] + (let [{:keys [q-table rewards]} (agent/train world options) + path (greedy-path world q-table) + end (peek path) + end-label (cond (= end :exhausted) "budget exhausted" + (world/goal? world end) "goal" + (world/hazard? world end) "hazard" + :else "cycle")] + (println (str "== " (:name world) " ==")) + (println (str " reward, first episode: " (format "%.3f" (first rewards)))) + (println (str " reward, last episode: " (format "%.3f" (last rewards)))) + (println (str " greedy path: " (pr-str path) " -> " end-label)))) + +(defn -main + "Train each built-in world and print the results." + [& _] + (doseq [world worlds/all] + (train-and-report world {:episodes 200 :seed 0 :epsilon 0.15 :epsilon-decay 0.995})) + (shutdown-agents)) diff --git a/src/gridmind/agent.clj b/src/gridmind/agent.clj new file mode 100644 index 0000000..1310f2a --- /dev/null +++ b/src/gridmind/agent.clj @@ -0,0 +1,127 @@ +(ns gridmind.agent + "Tabular reinforcement learning for grid worlds. + + An agent stores one value per state-action pair in a Q-table. + Q-learning and SARSA update the table while the agent explores. + A seeded random source makes every run deterministic." + + (:require [gridmind.world :as world])) + +(def ^:private default-options + {:alpha 0.5 + :gamma 1.0 + :epsilon 0.1 + :algorithm :q-learning}) + +(defn q-table + "Create an empty Q-table." + [] + (atom {})) + +(defn q-value + "Read the value of `state` and `action`. + Unknown pairs have a value of 0.0." + [q state action] + (get-in @q [state action] 0.0)) + +(defn update-q! + "Move the value of `state` and `action` toward `target` by `alpha`." + [q state action target alpha] + (let [current (q-value q state action)] + (swap! q assoc-in [state action] + (+ current (* alpha (- target current)))))) + +(defn greedy-action + "Return the action with the highest value for `state`. + Equal values resolve to the earlier action in world/actions." + [q state] + (reduce (fn [best action] + (if (> (q-value q state action) (q-value q state best)) + action + best)) + (first world/actions) + world/actions)) + +(defn epsilon-greedy + "Pick an action for `state` using epsilon-greedy exploration. + Pick a random action with probability `epsilon`. + Otherwise pick the greedy action. `rng` must be a java.util.Random." + [q state rng epsilon] + (if (< (.nextDouble rng) epsilon) + (nth world/actions (.nextInt rng (count world/actions))) + (greedy-action q state))) + +(defn run-episode + "Run one learning episode from the start of `world`. + + The agent picks actions with the epsilon-greedy policy. + It updates `q` after every step with Q-learning or SARSA. + Returns the total reward, the step count, and the terminal state. + When the step budget runs out, the terminal value is :exhausted." + [world q rng options] + (let [{:keys [alpha gamma epsilon algorithm]} + (merge default-options options) + first-action (epsilon-greedy q (:start world) rng epsilon)] + (loop [state (:start world) + action first-action + reward-total 0.0 + steps 0] + (cond + (world/terminal? world state) + {:reward reward-total :steps steps :terminal state} + + (>= steps (:max-steps world)) + {:reward reward-total :steps steps :terminal :exhausted} + + :else + (let [result (world/step world state action) + next-state (:state result) + reward (:reward result) + done (:done result) + next-action (when-not done + (if (= algorithm :sarsa) + (epsilon-greedy q next-state rng epsilon) + (greedy-action q next-state))) + target (if done + reward + (+ reward (* gamma (q-value q next-state next-action))))] + (update-q! q state action target alpha) + (recur next-state next-action + (+ reward-total reward) + (inc steps))))))) + +(defn train + "Train an agent on `world` for `episodes` episodes. + + Epsilon falls toward 0.01 by `epsilon-decay` after each episode. + A `seed` makes the whole run reproducible. + Returns a map with the final Q-table and one reward per episode." + [world {:keys [episodes seed epsilon-decay] :as options}] + (let [{:keys [epsilon] :as merged} (merge default-options options) + rng (java.util.Random. (or seed 0)) + q (q-table) + episodes (or episodes 100) + decay (or epsilon-decay 1.0)] + (loop [n 0 + eps epsilon + rewards []] + (if (>= n episodes) + {:q-table q :rewards rewards} + (recur (inc n) + (max 0.01 (* eps decay)) + (conj rewards + (:reward + (run-episode world q rng + (assoc merged :epsilon eps))))))))) + +(defn greedy-policy + "Return a deterministic policy for `q`. + The policy maps a state to its highest-value action." + [q] + (fn [state] (greedy-action q state))) + +(defn evaluate + "Walk `world` from its start with the greedy policy for `q`. + Returns the terminal state reached, or nil when the walk never ends." + [world q] + (world/follow-policy world (greedy-policy q))) diff --git a/src/gridmind/world.clj b/src/gridmind/world.clj index 6384634..0e94c4f 100644 --- a/src/gridmind/world.clj +++ b/src/gridmind/world.clj @@ -119,7 +119,7 @@ "Take `action` from `state`. Returns a map with the next state, the reward, and a `done` flag." [world state action] - (let [next (-> state (move world action) (wind-push world)) + (let [next (wind-push world (move world state action)) rewards (:rewards world) goal? (goal? world next) hazard? (hazard? world next) @@ -188,7 +188,7 @@ "start position is a wall") (when (and (:start world) (terminal? world (:start world))) "start position must not be a goal or a hazard") - (when-not (some goal? (states world)) + (when-not (some #(goal? world %) (states world)) "world must contain at least one goal") (when-let [bad (seq (remove (partial in-bounds? world) (keys (:tiles world))))] (str "tile positions out of bounds: " (pr-str (vec bad)))) diff --git a/src/gridmind/worlds.clj b/src/gridmind/worlds.clj index 4488aec..a91bb1f 100644 --- a/src/gridmind/worlds.clj +++ b/src/gridmind/worlds.clj @@ -3,6 +3,7 @@ Each world is loaded from an EDN file on the classpath. The files live under resources/gridmind/worlds." + (:refer-clojure :exclude [load]) (:require [gridmind.world :as world])) (def small diff --git a/test/gridmind/agent_test.clj b/test/gridmind/agent_test.clj new file mode 100644 index 0000000..192260f --- /dev/null +++ b/test/gridmind/agent_test.clj @@ -0,0 +1,111 @@ +(ns gridmind.agent-test + (:require [clojure.test :refer [deftest is testing]] + [gridmind.agent :as agent] + [gridmind.world :as world])) + +(def small (world/load-world "gridmind/worlds/small.edn")) +(def windy (world/load-world "gridmind/worlds/windy.edn")) + +(deftest q-table-starts-empty + (let [q (agent/q-table)] + (is (= 0.0 (agent/q-value q [0 0] :up))))) + +(deftest update-q-moves-toward-target + (let [q (agent/q-table)] + (agent/update-q! q [0 0] :up 4.0 0.5) + (is (= 2.0 (agent/q-value q [0 0] :up))) + (agent/update-q! q [0 0] :up 10.0 0.25) + (is (= 4.0 (agent/q-value q [0 0] :up))))) + +(deftest greedy-action-picks-the-maximum + (let [q (agent/q-table)] + (agent/update-q! q [0 0] :right 5.0 1.0) + (agent/update-q! q [0 0] :down 2.0 1.0) + (is (= :right (agent/greedy-action q [0 0]))))) + +(deftest greedy-action-ties-resolve-to-action-order + (let [q (agent/q-table)] + (is (= :up (agent/greedy-action q [0 0]))) + (agent/update-q! q [0 0] :left 0.0 1.0) + (agent/update-q! q [0 0] :right 0.0 1.0) + (is (= :up (agent/greedy-action q [0 0]))))) + +(deftest epsilon-zero-is-greedy + (let [q (agent/q-table) + rng (java.util.Random. 1)] + (agent/update-q! q [0 0] :right 9.0 1.0) + (is (= :right (agent/epsilon-greedy q [0 0] rng 0.0))))) + +(deftest epsilon-greedy-is-reproducible + (let [q (agent/q-table)] + (is (= (agent/epsilon-greedy q [0 0] (java.util.Random. 42) 0.5) + (agent/epsilon-greedy q [0 0] (java.util.Random. 42) 0.5))))) + +(deftest run-episode-is-reproducible + (let [q1 (agent/q-table) + q2 (agent/q-table) + options {:epsilon 0.1}] + (is (= (agent/run-episode small q1 (java.util.Random. 7) options) + (agent/run-episode small q2 (java.util.Random. 7) options))) + (is (= @q1 @q2)))) + +(deftest run-episode-tracks-reward-and-steps + (let [q (agent/q-table) + result (agent/run-episode small q (java.util.Random. 3) {:epsilon 1.0})] + (is (number? (:reward result))) + (is (pos? (:steps result))) + (is (world/terminal? small (:terminal result))))) + +(deftest step-budget-exhausts-gracefully + (let [world (-> (world/empty-world 1 4 [0 0]) + (world/set-rewards {:goal 1.0 :step 0.0}) + (assoc :max-steps 3) + (world/place [0 3] :goal)) + q (agent/q-table) + result (agent/run-episode world q (java.util.Random. 0) {:epsilon 0.0})] + (is (= :exhausted (:terminal result))) + (is (= 3 (:steps result))))) + +(deftest q-learning-reaches-goal-on-small-world + (let [{:keys [q-table rewards]} (agent/train small {:episodes 100 :seed 0})] + (is (= [0 3] (agent/evaluate small q-table))) + (is (= 100 (count rewards))) + (is (every? number? rewards)) + (is (> (last rewards) (first rewards))))) + +(deftest q-learning-improves-with-training + (let [{:keys [q-table rewards]} (agent/train small {:episodes 200 :seed 0 :epsilon-decay 0.99})] + (is (= [0 3] (agent/evaluate small q-table))) + (is (> (reduce + (take-last 20 rewards)) + (reduce + (take 20 rewards)))))) + +(deftest sarsa-reaches-goal-on-small-world + (let [{:keys [q-table]} (agent/train small + {:episodes 200 :seed 1 + :algorithm :sarsa :epsilon 0.1})] + (is (= [0 3] (agent/evaluate small q-table))))) + +(deftest q-learning-learns-the-windy-world + (let [{:keys [q-table]} (agent/train windy + {:episodes 300 :seed 0 + :epsilon 0.15 :epsilon-decay 0.995})] + (is (= [3 7] (agent/evaluate windy q-table))))) + +(deftest sarsa-learns-the-windy-world + (let [{:keys [q-table]} (agent/train windy + {:episodes 300 :seed 2 + :algorithm :sarsa + :epsilon 0.15 :epsilon-decay 0.995})] + (is (= [3 7] (agent/evaluate windy q-table))))) + +(deftest training-with-the-same-seed-matches + (let [a (agent/train small {:episodes 30 :seed 5}) + b (agent/train small {:episodes 30 :seed 5})] + (is (= @(:q-table a) @(:q-table b))) + (is (= (:rewards a) (:rewards b))))) + +(deftest greedy-policy-respects-the-q-table + (let [q (agent/q-table)] + (agent/update-q! q [0 0] :down 3.0 1.0) + (is (= :down ((agent/greedy-policy q) [0 0]))) + (is (fn? (agent/greedy-policy q))))) diff --git a/test/gridmind/run_tests.clj b/test/gridmind/run_tests.clj new file mode 100644 index 0000000..356abb3 --- /dev/null +++ b/test/gridmind/run_tests.clj @@ -0,0 +1,15 @@ +(ns gridmind.run-tests + "Entry point for the Clojure CLI test alias. + Runs every test namespace in the project." + + (:require [clojure.test :as test] + [gridmind.agent-test] + [gridmind.world-test])) + +(defn -main + "Run the full test suite. Exit with 0 on success, 1 on failure." + [& _] + (let [result (test/run-tests 'gridmind.agent-test 'gridmind.world-test) + failures (+ (:fail result) (:error result))] + (shutdown-agents) + (System/exit (if (pos? failures) 1 0)))) diff --git a/test/gridmind/world_test.clj b/test/gridmind/world_test.clj new file mode 100644 index 0000000..1ced63c --- /dev/null +++ b/test/gridmind/world_test.clj @@ -0,0 +1,46 @@ +(ns gridmind.world-test + (:require [clojure.test :refer [deftest is]] + [gridmind.world :as world] + [gridmind.worlds :as worlds])) + +(deftest step-moves-and-rewards + (is (= {:state [0 1] :reward -0.01 :done false} + (world/step worlds/small [0 0] :right))) + (is (= {:state [0 3] :reward 1.0 :done true} + (world/step worlds/small [0 2] :right))) + (is (= {:state [1 1] :reward -1.0 :done true} + (world/step worlds/small [1 0] :right)))) + +(deftest borders-block-movement + (is (= [0 0] (world/move worlds/small [0 0] :up))) + (is (= [0 0] (world/move worlds/small [0 0] :left))) + (is (= [2 3] (world/move worlds/small [2 3] :down))) + (is (= [2 3] (world/move worlds/small [2 3] :right)))) + +(deftest wind-pushes-the-agent-up + (is (= [3 1] (:state (world/step worlds/windy [3 0] :right)))) + (is (= [2 4] (:state (world/step worlds/windy [3 3] :right)))) + (is (= [1 7] (:state (world/step worlds/windy [3 6] :right)))) + (is (= [2 8] (:state (world/step worlds/windy [3 7] :right))))) + +(deftest wind-stops-at-the-top-border + (is (= [0 6] (:state (world/step worlds/windy [0 5] :right))))) + +(deftest states-exclude-walls + (let [world (-> (world/empty-world 2 2 [0 0]) + (world/place [1 0] :wall))] + (is (= #{[0 0] [0 1] [1 1]} (set (world/states world)))))) + +(deftest world-validation-finds-problems + (is (empty? (world/problems worlds/small))) + (is (seq (world/problems (assoc worlds/small :start [9 9])))) + (is (seq (world/problems (assoc worlds/small :tiles {})))) + (is (thrown? clojure.lang.ExceptionInfo (world/validate! (assoc worlds/small :start [9 9]))))) + +(deftest load-world-from-resource + (is (= "cliff" (:name worlds/cliff))) + (is (world/terminal? worlds/cliff [3 11])) + (is (world/hazard? worlds/cliff [3 5]))) + +(deftest worlds-load-by-name + (is (= worlds/small (worlds/load "small"))))