Cargo selection planning now works.

This commit is contained in:
Simon Brooke 2019-05-14 11:10:07 +01:00
parent 11506334f1
commit fcf8a8fa0d
3 changed files with 249 additions and 82 deletions

View file

@ -1,7 +1,7 @@
(ns the-great-game.merchants.merchants (ns the-great-game.merchants.merchants
"Trade planning for merchants, primarily." "Trade planning for merchants, primarily."
(:require [the-great-game.world.routes :refer [find-routes]] (: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 (defn expected-price
@ -36,7 +36,87 @@
(keys cargo))))) (keys cargo)))))
(defn find-trade-plan (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)))))
(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;
* :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 [m (cond
(keyword? merchant)
(-> world :merchants merchant)
(map? merchant)
merchant)
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 "Find the best destination in this `world` for this `commodity` given this
`merchant` and this `origin`. If two cities are anticipated to offer the `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 same price, the nearer should be preferred; if two are equally distant, the
@ -56,47 +136,51 @@
* :distance - the number of stages in the planned journey * :distance - the number of stages in the planned journey
* :dist-to-home - the distance from `destination` to the `merchant`'s * :dist-to-home - the distance from `destination` to the `merchant`'s
home city." 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] [merchant world commodity]
(let [m (cond (let [m (cond
(keyword? merchant) (keyword? merchant)
(-> world :merchants merchant) (-> world :merchants merchant)
(map? merchant) (map? merchant)
merchant) merchant)
origin (-> m :location) l (:location m)]
destinations (remove #(= % origin) (keys (-> world :cities))) (quot
plans (map (-> m :cash)
#(hash-map (-> world :cities l :prices commodity))))
: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)))))
(defn augment-plan (defn augment-plan
"Augment this `plan` constructed in this `world` for this `merchant` with "Augment this `plan` constructed in this `world` for this `merchant` with
@ -105,22 +189,21 @@
Returns the augmented plan." Returns the augmented plan."
[merchant world plan] [merchant world plan]
(let [m (cond (let [c (:commodity plan)
(keyword? merchant) o (:origin plan)
(-> world :merchants merchant) q (min
(map? merchant) (or
merchant) (-> world :cities o :stock c)
available (-> world :cities (:origin plan) :stock (:commodity plan)) 0)
can-carry (quot (can-carry merchant world c)
(- (-> m :capacity) (burden m world)) (can-afford merchant world c))
(-> world :commodities (:commodity plan) :weight))
can-afford (quot
(-> merchant :cash)
(-> world :commodities (:commodity plan) :weight))
q (min available can-carry can-afford)
p (* q (- (:expected-price plan) (:buy-price plan)))] p (* q (- (:expected-price plan) (:buy-price plan)))]
(assoc plan :quantity q :expected-profit p))) (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 (defn select-cargo
"A `merchant`, in a given location in a `world`, will choose to buy a cargo "A `merchant`, in a given location in a `world`, will choose to buy a cargo
@ -138,19 +221,17 @@
#(augment-plan #(augment-plan
m m
world world
(find-trade-plan m world %)) (plan-trade m world %))
(filter (filter
#(let [q (-> world :cities origin :stock %)] #(let [q (-> world :cities origin :stock %)]
(and (number? q) (> q 0))) (and (number? q) (> q 0)))
(keys available))) (keys available)))]
best-profit (apply min (filter number? (map :expected-profit plans)))
nearest (apply min (map :distance plans))]
(first (first
(sort (sort-by
#(compare (:dist-to-home %1) (:dist-to-home %2)) #(- 0 (:dist-to-home %))
(filter (filter
#(and (make-target-filter
(= (:expected-profit %) best-profit) [[:expected-profit
(= (:distance %) nearest)) (apply max (filter number? (map :expected-profit plans)))]])
plans))))) plans)))))

View file

@ -24,7 +24,7 @@
;; the end of each game day. ;; the end of each game day.
{:iron 1 {:iron 1
:cloth 10 :cloth 10
:tobacco 10} :whisky 10}
:port true :port true
:prices :prices
;; `prices`: the current price (both buying and selling, for simplicity) ;; `prices`: the current price (both buying and selling, for simplicity)
@ -34,7 +34,7 @@
:fish 1 :fish 1
:leather 1 :leather 1
:iron 1 :iron 1
:tobacco 1} :whisky 1}
:stock :stock
;; `stock` is the quantity of each commodity in the market at any ;; `stock` is the quantity of each commodity in the market at any
;; given time. It is adjusted for production and consumption at ;; given time. It is adjusted for production and consumption at
@ -43,7 +43,7 @@
:fish 0 :fish 0
:leather 0 :leather 0
:iron 0 :iron 0
:tobacco 0} :whisky 0}
:cash 100} :cash 100}
:buckie :buckie
{:id :buckie {:id :buckie
@ -52,19 +52,19 @@
:demands :demands
{:cloth 5 {:cloth 5
:leather 3 :leather 3
:tobacco 5 :whisky 5
:iron 1} :iron 1}
:port true :port true
:prices {:cloth 1 :prices {:cloth 1
:fish 1 :fish 1
:leather 1 :leather 1
:iron 1 :iron 1
:tobacco 1} :whisky 1}
:stock {:cloth 0 :stock {:cloth 0
:fish 0 :fish 0
:leather 0 :leather 0
:iron 0 :iron 0
:tobacco 0} :whisky 0}
:cash 100} :cash 100}
:callander :callander
{:id :callander {:id :callander
@ -72,18 +72,18 @@
:demands :demands
{:cloth 5 {:cloth 5
:fish 3 :fish 3
:tobacco 5 :whisky 5
:iron 1} :iron 1}
:prices {:cloth 1 :prices {:cloth 1
:fish 1 :fish 1
:leather 1 :leather 1
:iron 1 :iron 1
:tobacco 1} :whisky 1}
:stock {:cloth 0 :stock {:cloth 0
:fish 0 :fish 0
:leather 0 :leather 0
:iron 0 :iron 0
:tobacco 0} :whisky 0}
:cash 100} :cash 100}
:dundee {:id :dundee} :dundee {:id :dundee}
:edinburgh {:id :dundee} :edinburgh {:id :dundee}
@ -93,23 +93,23 @@
:demands :demands
{:cloth 5 {:cloth 5
:leather 3 :leather 3
:tobacco 5 :whisky 5
:fish 10} :fish 10}
:port true :port true
:prices {:cloth 1 :prices {:cloth 1
:fish 1 :fish 1
:leather 1 :leather 1
:iron 1 :iron 1
:tobacco 1} :whisky 1}
:stock {:cloth 0 :stock {:cloth 0
:fish 0 :fish 0
:leather 0 :leather 0
:iron 0 :iron 0
:tobacco 0} :whisky 0}
:cash 100} :cash 100}
:glasgow :glasgow
{:id :glasgow {:id :glasgow
:supplies {:tobacco 10} :supplies {:whisky 10}
:demands :demands
{:cloth 5 {:cloth 5
:leather 3 :leather 3
@ -120,12 +120,12 @@
:fish 1 :fish 1
:leather 1 :leather 1
:iron 1 :iron 1
:tobacco 1} :whisky 1}
:stock {:cloth 0 :stock {:cloth 0
:fish 0 :fish 0
:leather 0 :leather 0
:iron 0 :iron 0
:tobacco 0} :whisky 0}
:cash 100}} :cash 100}}
:merchants :merchants
{:archie {:id :archie {:archie {:id :archie
@ -171,7 +171,7 @@
{:cloth {:id :cloth :cost 1 :weight 0.25} {:cloth {:id :cloth :cost 1 :weight 0.25}
:fish {:id :fish :cost 1 :weight 1} :fish {:id :fish :cost 1 :weight 1}
:leather {:id :leather :cost 1 :weight 0.5} :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}}}) :iron {:id :iron :cost 1 :weight 10}}})
(defn actual-price (defn actual-price

View file

@ -21,3 +21,89 @@
(let [actual (expected-price (-> world :merchants :archie) :iron :buckie) (let [actual (expected-price (-> world :merchants :archie) :iron :buckie)
expected 1.7] ;; expected 1.7] ;;
(is (= actual expected) "if information select the most recent"))))) (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)))))