From fcf8a8fa0dba4d2d441ebd7cdd06b327db558a3b Mon Sep 17 00:00:00 2001 From: Simon Brooke Date: Tue, 14 May 2019 11:10:07 +0100 Subject: [PATCH] Cargo selection planning now works. --- src/the_great_game/merchants/merchants.clj | 213 ++++++++++++------ src/the_great_game/world/world.clj | 32 +-- .../merchants/merchants_test.clj | 86 +++++++ 3 files changed, 249 insertions(+), 82 deletions(-) diff --git a/src/the_great_game/merchants/merchants.clj b/src/the_great_game/merchants/merchants.clj index 74d650b..195495f 100644 --- a/src/the_great_game/merchants/merchants.clj +++ b/src/the_great_game/merchants/merchants.clj @@ -1,7 +1,7 @@ (ns the-great-game.merchants.merchants "Trade planning for merchants, primarily." (:require [the-great-game.world.routes :refer [find-routes]] - [the-great-game.world.world :refer [actual-price]])) + [the-great-game.world.world :refer [actual-price default-world]])) (defn expected-price @@ -36,15 +36,31 @@ (keys cargo))))) -(defn find-trade-plan - "Find the best destination in this `world` for this `commodity` given this - `merchant` and this `origin`. If two cities are anticipated to offer the - same price, the nearer should be preferred; if two are equally distant, the - ones nearer to the merchant's home should be preferred. - `merchant` may be passed as a map or a keyword; `commodity` should be - passed as a keyword. +(defn make-target-filter + "Construct a filter which, when applied to a list of maps, + will pass those which match these `targets`, where each target + is a tuple [key value]." + ;; TODO: this would probably be more elegant as a macro + [targets] + (eval + (list + 'fn + (vector 'plan) + (cons + 'and + (map + #(list + '= + (list (first %) 'plan) + (nth % 1)) + targets))))) - The returned plan is a map with keys: + +(defn generate-trade-plans + "Generate all possible trade plans for this `merchant` and this `commodity` + in this `world`. + + Returned plans are maps with keys: * :merchant - the id of the `merchant` for whom the plan was created; * :origin - the city from which the trade starts; @@ -62,41 +78,109 @@ (-> world :merchants merchant) (map? merchant) merchant) - origin (-> m :location) - destinations (remove #(= % origin) (keys (-> world :cities))) - plans (map - #(hash-map - :merchant (-> m :id) - :origin origin - :destination % - :commodity commodity - :buy-price (actual-price world commodity origin) - :expected-price (expected-price - merchant - commodity - %) - :distance (count - (first - (find-routes (:routes world) origin %))) - :dist-to-home (count - (first - (find-routes - (:routes world) - (-> world :merchants merchant :home) - %))) - ) - destinations) - best-price (apply min (filter number? (map :expected-price plans))) - nearest (apply min (map :distance plans))] - (first - (sort - #(compare (:dist-to-home %1) (:dist-to-home %2)) - (filter - #(and - (= (:expected-price %) best-price) - (= (:distance %) nearest)) - plans))))) + origin (-> m :location)] + (map + #(hash-map + :merchant (-> m :id) + :origin origin + :destination % + :commodity commodity + :buy-price (actual-price world commodity origin) + :expected-price (expected-price + m + commodity + %) + :distance (count + (first + (find-routes (:routes world) origin %))) + :dist-to-home (count + (first + (find-routes + (:routes world) + (:home m) + %))) + ) + (remove #(= % origin) (keys (-> world :cities)))))) +(defn nearest-with-targets + "Return the distance to the nearest destination among those of these + `plans` which match these `targets`. Plans are expected to be plans + as returned by `generate-trade-plans`, q.v.; `targets` are expected to be + as accepted by `make-target-filter`, q.v." + [plans targets] + (apply + min + (map + :distance + (filter + (make-target-filter targets) + plans)))) + +(defn plan-trade + "Find the best destination in this `world` for this `commodity` given this + `merchant` and this `origin`. If two cities are anticipated to offer the + same price, the nearer should be preferred; if two are equally distant, the + ones nearer to the merchant's home should be preferred. + `merchant` may be passed as a map or a keyword; `commodity` should be + passed as a keyword. + + The returned plan is a map with keys: + + * :merchant - the id of the `merchant` for whom the plan was created; + * :origin - the city from which the trade starts; + * :destination - the city to which the trade is planned; + * :commodity - the `commodity` to be carried; + * :buy-price - the price at which that `commodity` can be bought; + * :expected-price - the price at which the `merchant` anticipates + that `commodity` can be sold; + * :distance - the number of stages in the planned journey + * :dist-to-home - the distance from `destination` to the `merchant`'s + home city." + [merchant world commodity] + (let [plans (generate-trade-plans merchant world commodity) + best-prices (filter + (make-target-filter + [[:expected-price + (apply + max + (filter number? (map :expected-price plans)))]]) + plans)] + (first + (sort-by + ;; all other things being equal, a merchant would prefer to end closer + ;; to home. + #(- 0 (:dist-to-home %)) + ;; a merchant will seek the best price, but won't go further than + ;; needed to get it. + (filter + (make-target-filter + [[:distance + (apply min (filter number? (map :distance best-prices)))]]) + best-prices))))) + + +(defn can-carry + [merchant world commodity] + (let [m (cond + (keyword? merchant) + (-> world :merchants merchant) + (map? merchant) + merchant)] + (quot + (- (:capacity m) (burden m world)) + (-> world :commodities commodity :weight)))) + +(defn can-afford + [merchant world commodity] + (let [m (cond + (keyword? merchant) + (-> world :merchants merchant) + (map? merchant) + merchant) + l (:location m)] + (quot + (-> m :cash) + (-> world :cities l :prices commodity)))) (defn augment-plan "Augment this `plan` constructed in this `world` for this `merchant` with @@ -105,22 +189,21 @@ Returns the augmented plan." [merchant world plan] - (let [m (cond - (keyword? merchant) - (-> world :merchants merchant) - (map? merchant) - merchant) - available (-> world :cities (:origin plan) :stock (:commodity plan)) - can-carry (quot - (- (-> m :capacity) (burden m world)) - (-> world :commodities (:commodity plan) :weight)) - can-afford (quot - (-> merchant :cash) - (-> world :commodities (:commodity plan) :weight)) - q (min available can-carry can-afford) + (let [c (:commodity plan) + o (:origin plan) + q (min + (or + (-> world :cities o :stock c) + 0) + (can-carry merchant world c) + (can-afford merchant world c)) p (* q (- (:expected-price plan) (:buy-price plan)))] (assoc plan :quantity q :expected-profit p))) +;; (-> default-world :cities :buckie :stock :iron) +;; (burden :fiona default-world) +;; (-> default-world :commodities :iron :weight) +;; (quot 0 10) (defn select-cargo "A `merchant`, in a given location in a `world`, will choose to buy a cargo @@ -138,19 +221,17 @@ #(augment-plan m world - (find-trade-plan m world %)) + (plan-trade m world %)) (filter #(let [q (-> world :cities origin :stock %)] (and (number? q) (> q 0))) - (keys available))) - best-profit (apply min (filter number? (map :expected-profit plans))) - nearest (apply min (map :distance plans))] + (keys available)))] (first - (sort - #(compare (:dist-to-home %1) (:dist-to-home %2)) + (sort-by + #(- 0 (:dist-to-home %)) (filter - #(and - (= (:expected-profit %) best-profit) - (= (:distance %) nearest)) - plans))) )) + (make-target-filter + [[:expected-profit + (apply max (filter number? (map :expected-profit plans)))]]) + plans))))) diff --git a/src/the_great_game/world/world.clj b/src/the_great_game/world/world.clj index 32a639a..4e914fc 100644 --- a/src/the_great_game/world/world.clj +++ b/src/the_great_game/world/world.clj @@ -24,7 +24,7 @@ ;; the end of each game day. {:iron 1 :cloth 10 - :tobacco 10} + :whisky 10} :port true :prices ;; `prices`: the current price (both buying and selling, for simplicity) @@ -34,7 +34,7 @@ :fish 1 :leather 1 :iron 1 - :tobacco 1} + :whisky 1} :stock ;; `stock` is the quantity of each commodity in the market at any ;; given time. It is adjusted for production and consumption at @@ -43,7 +43,7 @@ :fish 0 :leather 0 :iron 0 - :tobacco 0} + :whisky 0} :cash 100} :buckie {:id :buckie @@ -52,19 +52,19 @@ :demands {:cloth 5 :leather 3 - :tobacco 5 + :whisky 5 :iron 1} :port true :prices {:cloth 1 :fish 1 :leather 1 :iron 1 - :tobacco 1} + :whisky 1} :stock {:cloth 0 :fish 0 :leather 0 :iron 0 - :tobacco 0} + :whisky 0} :cash 100} :callander {:id :callander @@ -72,18 +72,18 @@ :demands {:cloth 5 :fish 3 - :tobacco 5 + :whisky 5 :iron 1} :prices {:cloth 1 :fish 1 :leather 1 :iron 1 - :tobacco 1} + :whisky 1} :stock {:cloth 0 :fish 0 :leather 0 :iron 0 - :tobacco 0} + :whisky 0} :cash 100} :dundee {:id :dundee} :edinburgh {:id :dundee} @@ -93,23 +93,23 @@ :demands {:cloth 5 :leather 3 - :tobacco 5 + :whisky 5 :fish 10} :port true :prices {:cloth 1 :fish 1 :leather 1 :iron 1 - :tobacco 1} + :whisky 1} :stock {:cloth 0 :fish 0 :leather 0 :iron 0 - :tobacco 0} + :whisky 0} :cash 100} :glasgow {:id :glasgow - :supplies {:tobacco 10} + :supplies {:whisky 10} :demands {:cloth 5 :leather 3 @@ -120,12 +120,12 @@ :fish 1 :leather 1 :iron 1 - :tobacco 1} + :whisky 1} :stock {:cloth 0 :fish 0 :leather 0 :iron 0 - :tobacco 0} + :whisky 0} :cash 100}} :merchants {:archie {:id :archie @@ -171,7 +171,7 @@ {:cloth {:id :cloth :cost 1 :weight 0.25} :fish {:id :fish :cost 1 :weight 1} :leather {:id :leather :cost 1 :weight 0.5} - :tobacco {:id :tobacco :cost 1 :weight 0.1} + :whisky {:id :whisky :cost 1 :weight 0.1} :iron {:id :iron :cost 1 :weight 10}}}) (defn actual-price diff --git a/test/the_great_game/merchants/merchants_test.clj b/test/the_great_game/merchants/merchants_test.clj index f4d7ed3..8089c86 100644 --- a/test/the_great_game/merchants/merchants_test.clj +++ b/test/the_great_game/merchants/merchants_test.clj @@ -21,3 +21,89 @@ (let [actual (expected-price (-> world :merchants :archie) :iron :buckie) expected 1.7] ;; (is (= actual expected) "if information select the most recent"))))) + +(deftest plan-trade-test + (testing "Lower level trade planning" + (let [world (deep-merge + default-world + {:merchants + {:fiona + {:known-prices + {:aberdeen + {:iron + [{:price 1.5 :date 1} + {:price 1.3 :date 0}]} + :buckie + {:iron + [{:price 1.7 :date 1} + {:price 2 :date 0}]}}}} + :cities + {:falkirk + {:stock {:iron 20}}}}) + actual (plan-trade :fiona world :iron)] + (is (= (:origin actual) :falkirk) + "Fiona is in Falkirk, so her plan must originate there") + (is (= (:commodity actual) :iron) + "Iron is the only thing available in Falkirk, so plan must carry iron") + (is (= (:destination actual) :buckie) + "Fiona believes Buckie offers the best price for iron, so should go there")))) + + +(deftest select-cargo-test + (testing "Top level single trade planning: single candidate commodity" + (let [world (deep-merge + default-world + {:merchants + {:fiona + {:known-prices + {:aberdeen + {:iron + [{:price 1.5 :date 1} + {:price 1.3 :date 0}]} + :buckie + {:iron + [{:price 1.7 :date 1} + {:price 2 :date 0}]}}}} + :cities + {:falkirk + {:stock {:iron 20}}}}) + actual (select-cargo :fiona world)] + (is (= (:origin actual) :falkirk) + "Fiona is in Falkirk, so her plan must originate there") + (is (= (:commodity actual) :iron) + "Iron is the only thing available in Falkirk, so plan must carry iron") + (is (= (:destination actual) :buckie) + "Fiona believes Buckie offers the best price for iron, so should go there") + (is (= (:quantity actual) 1) + "Fiona can carry only one unit of iron.") + (is (= (:expected-profit actual) 0.7)))) + (testing "Top level single trade planning: multiple candidate commodities" + (let [world (deep-merge + default-world + {:merchants + {:fiona + {:known-prices + {:aberdeen + {:iron + [{:price 1.5 :date 1} + {:price 1.3 :date 0}] + :whisky [{:price 4 :date 0}]} + :buckie + {:iron + [{:price 1.7 :date 1} + {:price 2 :date 0}]}}}} + :cities + {:falkirk + {:stock + {:iron 20 + :whisky 50}}}}) + actual (select-cargo :fiona world)] + (is (= (:origin actual) :falkirk) + "Fiona is in Falkirk, so her plan must originate there") + (is (= (:commodity actual) :whisky) + "Whisky has the higher profit, so plan must carry whisky") + (is (= (:destination actual) :aberdeen) + "Fiona believes Aberdeen offers the best price for whisky, so should go there") + (is (= (:quantity actual) 50) + "Fiona can carry 100 units of whisky, but only 50 are available.") + (is (= (:expected-profit actual) 150)))))