Merchant route selection: very basic stuff works

Tests pass.
This commit is contained in:
Simon Brooke 2019-05-13 21:46:43 +01:00
parent a7f90fa60f
commit 5619b9c1e5
15 changed files with 694 additions and 299 deletions

View file

@ -0,0 +1,6 @@
(ns the-great-game.core)
(defn foo
"I don't do a whole lot."
[x]
(println x "Hello, World!"))

View file

@ -0,0 +1,156 @@
(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]]))
(defn expected-price
"Find the price anticipated, given this `world`, by this `merchant` for
this `commodity` in this `city`. If no information, assume 1.
`merchant` should be passed as a map, `commodity` and `city` should be passed as keywords."
[merchant commodity city]
(or
(:price
(last
(sort-by
:date
(-> merchant :known-prices city commodity))))
1))
(defn burden
"The total weight of the current cargo carried by this `merchant` in this
`world`."
[merchant world]
(let [m (cond
(keyword? merchant)
(-> world :merchants merchant)
(map? merchant)
merchant)
cargo (-> m :stock)]
(reduce
+
0
(map
#(* (cargo %) (-> world :commodities % :weight))
(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.
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 [m (cond
(keyword? merchant)
(-> 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)))))
(defn augment-plan
"Augment this `plan` constructed in this `world` for this `merchant` with
the `:quantity` of goods which should be bought and the `:expected-profit`
of the trade.
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)
p (* q (- (:expected-price plan) (:buy-price plan)))]
(assoc plan :quantity q :expected-profit p)))
(defn select-cargo
"A `merchant`, in a given location in a `world`, will choose to buy a cargo
within the limit they are capable of carrying, which they can anticipate
selling for a profit at a destination."
[merchant world]
(let [m (cond
(keyword? merchant)
(-> world :merchants merchant)
(map? merchant)
merchant)
origin (-> m :location)
available (-> world :cities origin :stock)
plans (map
#(augment-plan
m
world
(find-trade-plan 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))]
(first
(sort
#(compare (:dist-to-home %1) (:dist-to-home %2))
(filter
#(and
(= (:expected-profit %) best-profit)
(= (:distance %) nearest))
plans))) ))

View file

@ -0,0 +1,17 @@
(ns the-great-game.utils)
(defn cyclic?
"True if two or more elements of `route` are identical"
[route]
(not (= (count route)(count (set route)))))
(defn deep-merge
"Recursively merges maps. Stolen from
https://dnaeon.github.io/recursively-merging-maps-in-clojure/"
[& maps]
(letfn [(m [& xs]
(if (some #(and (map? %) (not (record? %))) xs)
(apply merge-with m xs)
(last xs)))]
(reduce m maps)))

View file

@ -0,0 +1,46 @@
(ns the-great-game.world.routes
"Conceptual (plan level) routes, represented as tuples of location ids."
(:require [the-great-game.utils :refer [cyclic?]]))
(defn find-routes
"Find routes from among these `routes` from `from`; if `to` is supplied,
to `to`, by breadth-first search."
([routes from]
(map
(fn [to] (cons from to))
(remove
#(empty? %)
(map
(fn [route]
(filter
#(not (= from %))
(if (some #(= % from) route) route)))
routes))))
([routes from to]
(let [steps (find-routes routes from)
found (filter
(fn [step] (if (some #(= to %) step) step))
steps)]
(if
(empty? found)
(find-routes routes from to steps)
found)))
([routes from to steps]
(if
(not (empty? steps))
(let [paths (remove
cyclic?
(apply
concat
(map
(fn [path]
(map
(fn [x] (concat path (rest x)))
(find-routes routes (last path))))
steps)))
found (filter
#(= (last %) to) paths)]
(if
(empty? found)
(find-routes routes from to paths)
found)))))

View file

@ -0,0 +1,184 @@
(ns the-great-game.world.world
"Access to data about the world")
;;; The world has to work either as map or a database. Initially, and for
;;; unit tests, I'll use a map; later, there will be a database. But the
;;; API needs to be agnostic, so that heirarchies which interact with
;;; `world` don't have to know which they've got - as far as they're concerned
;;; it's just a handle.
(def default-world
"A basic world for testing concepts"
{:cities
{:aberdeen
{:id :aberdeen
:supplies
;; `supplies` is the quantity of each commodity added to the stock
;; each game day. If the price in the market is lower than 1 (the
;; cost of production of a unit) no goods will be added.
{:fish 10
:leather 5}
:demands
;; `stock` is the quantity of each commodity in the market at any
;; given time. It is adjusted for production and consumption at
;; the end of each game day.
{:iron 1
:cloth 10
:tobacco 10}
:port true
:prices
;; `prices`: the current price (both buying and selling, for simplicity)
;; of each commodity in the market. Updated each game day based on current
;; stock.
{:cloth 1
:fish 1
:leather 1
:iron 1
:tobacco 1}
:stock
;; `stock` is the quantity of each commodity in the market at any
;; given time. It is adjusted for production and consumption at
;; the end of each game day.
{:cloth 0
:fish 0
:leather 0
:iron 0
:tobacco 0}
:cash 100}
:buckie
{:id :buckie
:supplies
{:fish 20}
:demands
{:cloth 5
:leather 3
:tobacco 5
:iron 1}
:port true
:prices {:cloth 1
:fish 1
:leather 1
:iron 1
:tobacco 1}
:stock {:cloth 0
:fish 0
:leather 0
:iron 0
:tobacco 0}
:cash 100}
:callander
{:id :callander
:supplies {:leather 20}
:demands
{:cloth 5
:fish 3
:tobacco 5
:iron 1}
:prices {:cloth 1
:fish 1
:leather 1
:iron 1
:tobacco 1}
:stock {:cloth 0
:fish 0
:leather 0
:iron 0
:tobacco 0}
:cash 100}
:dundee {:id :dundee}
:edinburgh {:id :dundee}
:falkirk
{:id :falkirk
:supplies {:iron 10}
:demands
{:cloth 5
:leather 3
:tobacco 5
:fish 10}
:port true
:prices {:cloth 1
:fish 1
:leather 1
:iron 1
:tobacco 1}
:stock {:cloth 0
:fish 0
:leather 0
:iron 0
:tobacco 0}
:cash 100}
:glasgow
{:id :glasgow
:supplies {:tobacco 10}
:demands
{:cloth 5
:leather 3
:iron 5
:fish 10}
:port true
:prices {:cloth 1
:fish 1
:leather 1
:iron 1
:tobacco 1}
:stock {:cloth 0
:fish 0
:leather 0
:iron 0
:tobacco 0}
:cash 100}}
:merchants
{:archie {:id :archie
:home :aberdeen :location :aberdeen :cash 100 :capacity 10
:known-prices {}
:stock {}}
:belinda {:id :belinda
:home :buckie :location :buckie :cash 100 :capacity 10
:known-prices {}
:stock {}}
:callum {:id :callum
:home :callander :location :calander :cash 100 :capacity 10
:known-prices {}
:stock {}}
:deirdre {:id :deidre
:home :dundee :location :dundee :cash 100 :capacity 10
:known-prices {}
:stock {}}
:euan {:id :euan
:home :edinbirgh :location :edinburgh :cash 100 :capacity 10
:known-prices {}
:stock {}}
:fiona {:id :fiona
:home :falkirk :location :falkirk :cash 100 :capacity 10
:known-prices {}
:stock {}}}
:routes
;; all routes can be traversed in either direction and are assumed to
;; take the same amount of time.
[[:aberdeen :buckie]
[:aberdeen :dundee]
[:callander :glasgow]
[:dundee :callander]
[:dundee :edinburgh]
[:dundee :falkirk]
[:edinburgh :falkirk]
[:falkirk :glasgow]]
:commodities
;; cost of commodities is expressed in person/days;
;; weight in packhorse loads. Transport in this model
;; is all overland; you don't take bulk cargoes overland
;; in this period, it's too expensive.
{: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}
:iron {:id :iron :cost 1 :weight 10}}})
(defn actual-price
"Find the actual current price of this `commodity` in this `city` given
this `world`. **NOTE** that merchants can only know the actual prices in
the city in which they are currently located."
[world commodity city]
(-> world :cities city :prices commodity))