diff --git a/packages/ludic.crafting/hold.ludic b/packages/ludic.crafting/hold.ludic index 12e46f16..2104dac6 100644 --- a/packages/ludic.crafting/hold.ludic +++ b/packages/ludic.crafting/hold.ludic @@ -1,42 +1,42 @@ # hold.ludic - a recipe made by holding a button for its `hold` seconds: letting go wastes # nothing, and it stops by itself the moment it can no longer be made -var cr_hold: int = -1 -var cr_hold_t: float = 0.0 +var craft_hold_i: int = -1 +var craft_hold_t: float = 0.0 export function craft_holds(i: int) -> bool { return craft_ok(i) and CraftRecipes[i].hold > 0.0 } # the recipe being held, or -1 -export function craft_holding() -> int { return cr_hold } +export function craft_holding() -> int { return craft_hold_i } # starts holding one that is made by holding and can be made now export function craft_hold_start(i: int) -> bool { if not craft_holds(i) or not craft_can(i) { return false } - cr_hold = i - cr_hold_t = 0.0 + craft_hold_i = i + craft_hold_t = 0.0 return true } export function craft_hold_stop() -> void { - cr_hold = -1 - cr_hold_t = 0.0 + craft_hold_i = -1 + craft_hold_t = 0.0 } # how far through the hold on `i` is, 0 to 1 export function craft_hold_frac(i: int) -> float { - if cr_hold != i or not craft_holds(i) { return 0.0 } - return Math.clamp(cr_hold_t / CraftRecipes[i].hold, 0.0, 1.0) + if craft_hold_i != i or not craft_holds(i) { return 0.0 } + return Math.clamp(craft_hold_t / CraftRecipes[i].hold, 0.0, 1.0) } # `dt` real seconds of holding; true on the frame it is made export function craft_hold_run(dt: float) -> bool { - if cr_hold < 0 { return false } - if not craft_can(cr_hold) { + if craft_hold_i < 0 { return false } + if not craft_can(craft_hold_i) { craft_hold_stop() return false } - cr_hold_t = cr_hold_t + dt - if cr_hold_t <= CraftRecipes[cr_hold].hold { return false } - let i = cr_hold + craft_hold_t = craft_hold_t + dt + if craft_hold_t <= CraftRecipes[craft_hold_i].hold { return false } + let i = craft_hold_i craft_hold_stop() return craft_make(i) } diff --git a/packages/ludic.crafting/ports.ludic b/packages/ludic.crafting/ports.ludic index 4b2433f9..a901362b 100644 --- a/packages/ludic.crafting/ports.ludic +++ b/packages/ludic.crafting/ports.ludic @@ -4,17 +4,17 @@ export port CraftPack { count: fn(int) -> int take: fn(int, int) -> bool add: fn(int, int) -> int - room: fn(int) -> int = fn cr_no_limit - leaves: fn(int) -> int = fn cr_leaves_nothing + room: fn(int) -> int = fn craft_no_limit + leaves: fn(int) -> int = fn craft_leaves_nothing } -function cr_no_limit(it: int) -> int { return 2147483647 } -function cr_leaves_nothing(it: int) -> int { return -1 } +function craft_no_limit(it: int) -> int { return 2147483647 } +function craft_leaves_nothing(it: int) -> int { return -1 } # the stations. Unbound, there are none: only the hands are ready, and nothing can be started. # `start(station, recipe)` puts a timed recipe in; false gives the makings back. export port CraftStations { - ready: fn(int) -> bool = fn cr_hands_only - start: fn(int, int) -> bool = fn cr_no_start + ready: fn(int) -> bool = fn craft_hands_only + start: fn(int, int) -> bool = fn craft_no_start } -function cr_hands_only(station: int) -> bool { return station == CRAFT_HANDS } -function cr_no_start(station: int, recipe: int) -> bool { return false } +function craft_hands_only(station: int) -> bool { return station == CRAFT_HANDS } +function craft_no_start(station: int, recipe: int) -> bool { return false } diff --git a/packages/ludic.crafting/verbs.ludic b/packages/ludic.crafting/verbs.ludic index b4528dbb..f50a8624 100644 --- a/packages/ludic.crafting/verbs.ludic +++ b/packages/ludic.crafting/verbs.ludic @@ -1,9 +1,9 @@ # verbs.ludic - can it be made, taking and giving back its makings, and making it -var cr_facts: Queue = null +var craft_fact_q: Queue = null export function craft_facts() -> Queue { - if cr_facts == null { cr_facts = queue_new("crafting.crafted") } - return cr_facts + if craft_fact_q == null { craft_fact_q = queue_new("crafting.crafted") } + return craft_fact_q } # every making is in the pack @@ -46,7 +46,7 @@ export function craft_refund(i: int) -> void { } } -function cr_fact(i: int, queued: bool) -> void { +function craft_fact_push(i: int, queued: bool) -> void { let r = CraftRecipes[i] let c = new Crafted c.recipe = i @@ -68,10 +68,10 @@ export function craft_make(i: int) -> bool { craft_refund(i) return false } - cr_fact(i, true) + craft_fact_push(i, true) return true } CraftPack.add(r.out, r.n) - cr_fact(i, false) + craft_fact_push(i, false) return true } diff --git a/packages/ludic.shop/README.md b/packages/ludic.shop/README.md new file mode 100644 index 00000000..78a14075 --- /dev/null +++ b/packages/ludic.shop/README.md @@ -0,0 +1,60 @@ +# ludic.shop + +Vendors behind counters: what each sells and buys, what a thing costs and fetches at the +player's standing, what each vendor is short of this week, and buying and selling through the +game's money and pack. Uses [`ludic.base`](../ludic.base/README.md) and nothing else. + +```ludic +import "ludic.shop" +``` + +Items and vendors are the game's data, handed over as it boots: every item's base price and the +fraction of the curve a vendor pays for it (`shop_config_items`), and every vendor's shelves, what +it buys and the one thing it refuses (`shop_add_vendor`, numbered in the order added). A vendor is +a number; its name, face and words stay the game's. + +## The rules it keeps + +These hold whatever the numbers become, and the tests hold them: + +1. **A shop is not a fountain.** A vendor never pays as much for a thing it stocks as it charges + for it - not in a week it wants them, not at the best standing, and not even by a $1 floor: a + penny item it stocks fetches nothing. +2. **Friction falls with standing and never inverts.** Buying runs from 1.15x base at standing 0 + to 0.92x at 100, selling from 0.62x to 0.85x (times the item's fraction), so a newcomer pays a + little over and sells a little under, and the two never cross. +3. **Raw material keeps its order.** A sale is ROUNDED, not truncated, so cheap things spanning a + wide range of base prices do not all land on $1. + +## The ports + +```ludic +export port ShopWorld { + rep: fn() -> int # standing, 0 to 100 + day: fn() -> int # the game day; the demand turns every week (unbound: 1) + buy_mult: fn() -> float # a shortage on everything bought (unbound: 1) + also_buys: fn(int, int) -> bool # (vendor, item) taken beyond its list (unbound: none) +} +export port ShopPurse { money: fn() -> int, spend: fn(int) -> bool, earn: fn(int) -> void } +export port ShopPack { count, take, add, room } # room unbound: no limit +``` + +## API + +| | | +| --- | --- | +| `shop_config_items(prices, sell)`, `shop_clear_vendors()`, `shop_add_vendor(stock, buys, refuses) -> int` | the data | +| `shop_config_curve(buy_new, buy_best, sell_new, sell_best)`, `shop_config_demand(wants, want_mult, glut_mult, days, seed)` | the numbers (defaults 1.15, 0.92, 0.62, 0.85; 3 wanted at 1.5x, one glut at 0.6x, 7 days, seed 1013) | +| `shop_visit(v)`, `shop_away()`, `shop_vendor()`, `shop_at_counter()` | who is behind the counter (-1 nobody) | +| `shop_price_buy(it)`, `shop_price_sell(it)`, `shop_price_sell_at(v, it)`, `shop_buy_factor()`, `shop_sell_factor()` | prices; with no vendor, no demand and no fountain guard | +| `shop_stocks(v, it)`, `shop_buys(v, it)`, `shop_stock(v)`, `shop_sellable(v)` | shelves, and what in the pack a vendor takes | +| `shop_wants(v, it)`, `shop_glutted(v, it)`, `shop_wanted(v)`, `shop_demand_mult(v, it)`, `shop_week()`, `shop_set_want(v, it)` | the week's demand, from the shop's own `Rng` seeded by the week | +| `shop_can_buy(it)`, `shop_buy(it) -> bool`, `shop_sell(it, n) -> int` | one bought; up to `n` sold to the vendor at the counter | +| `shop_trades() -> Queue` | `{ what: SHOP_BOUGHT / SHOP_SOLD, vendor, item, n, price }` | +| `shop_reset()`, `shop_system() -> System` | `"shop"`, `PH_COMMIT`: a reset; nothing is saved (the week follows from the day) | + +## Tests + +```bash +ludic test packages/ludic.shop +``` diff --git a/packages/ludic.shop/demand.ludic b/packages/ludic.shop/demand.ludic new file mode 100644 index 00000000..7cf99bda --- /dev/null +++ b/packages/ludic.shop/demand.ludic @@ -0,0 +1,104 @@ +# demand.ludic - what each vendor is short of this week, and the one thing it is full of: rolled +# from the shop's own dice, seeded by the week, so it never moves anyone else's rolls +var shop_wants_n: int = 3 +var shop_want_mult: float = 1.5 +var shop_glut_mult: float = 0.6 +var shop_week_days: int = 7 +var shop_seed: int = 1013 +var shop_week_rolled: int = -1 +var shop_want_list: []int = null # shop_wants_n per vendor, -1 empty +var shop_full_list: []int = null # one per vendor, -1 none +var shop_dice: Rng = null + +# how many things a vendor wants a week, what that pays and what a glut pays, the week, the seed +export function shop_config_demand(wants: int, want_mult: float, glut_mult: float, days: int, seed: int) -> void { + shop_wants_n = wants + shop_want_mult = want_mult + shop_glut_mult = glut_mult + shop_week_days = days + shop_seed = seed + shop_week_rolled = -1 +} + +export function shop_week() -> int { return (ShopWorld.day() - 1) / shop_week_days } + +function shop_pick_one(v: int, taken: []int) -> int { + for t in 0 .. 200 { + let it = rng_between(shop_dice, 0, shop_items() - 1) + if not shop_buys(v, it) { continue } + var dup = false + for k in 0 .. len(taken) { if taken[k] == it { dup = true } } + if not dup { return it } + } + return -1 +} + +function shop_roll_vendor(v: int) -> void { + let taken = new []int + for k in 0 .. shop_wants_n { + let it = shop_pick_one(v, taken) + shop_want_list[v * shop_wants_n + k] = it + if it >= 0 { push(taken, it) } + } + shop_full_list[v] = shop_pick_one(v, taken) +} + +# the week's lists, rolled again only when the week turns +function shop_roll() -> void { + let week = shop_week() + if week == shop_week_rolled and shop_want_list != null { return } + shop_week_rolled = week + shop_dice = rng_new(shop_seed + week * 37) + shop_want_list = new []int + shop_full_list = new []int + for i in 0 .. shop_vendors() * shop_wants_n { push(shop_want_list, -1) } + for v in 0 .. shop_vendors() { push(shop_full_list, -1) } + if shop_items() == 0 { return } + for v in 0 .. shop_vendors() { shop_roll_vendor(v) } +} + +export function shop_wants(v: int, it: int) -> bool { + if not shop_vendor_ok(v) { return false } + shop_roll() + for k in 0 .. shop_wants_n { if shop_want_list[v * shop_wants_n + k] == it { return true } } + return false +} + +export function shop_glutted(v: int, it: int) -> bool { + if not shop_vendor_ok(v) { return false } + shop_roll() + return shop_full_list[v] == it +} + +# what a vendor wants this week, in its order +export function shop_wanted(v: int) -> []int { + let out = new []int + if not shop_vendor_ok(v) { return out } + shop_roll() + for k in 0 .. shop_wants_n { + let it = shop_want_list[v * shop_wants_n + k] + if it >= 0 { push(out, it) } + } + return out +} + +# the week's multiplier on what `v` pays for `it` (1 with no vendor) +export function shop_demand_mult(v: int, it: int) -> float { + if shop_wants(v, it) { return shop_want_mult } + if shop_glutted(v, it) { return shop_glut_mult } + return 1.0 +} + +# this week, `v` wants `it` (a test, staging, a board that says so) +export function shop_set_want(v: int, it: int) -> void { + if not shop_vendor_ok(v) { return } + shop_roll() + shop_want_list[v * shop_wants_n] = it + if shop_full_list[v] == it { shop_full_list[v] = -1 } +} + +function shop_demand_reset() -> void { + shop_week_rolled = -1 + shop_want_list = null + shop_full_list = null +} diff --git a/packages/ludic.shop/index.ludic b/packages/ludic.shop/index.ludic new file mode 100644 index 00000000..9464999a --- /dev/null +++ b/packages/ludic.shop/index.ludic @@ -0,0 +1,10 @@ +# ludic.shop - the counter: items' base prices and vendors' shelves are the game's data, money and +# the pack are asked through ports, and every sale or purchase is a fact +module ludic_shop uses ludic_base +numbers float +import "ludic.base" +import "state.ludic" +import "demand.ludic" +import "prices.ludic" +import "trade.ludic" +import "system.ludic" diff --git a/packages/ludic.shop/package.ludic b/packages/ludic.shop/package.ludic new file mode 100644 index 00000000..3462c8d8 --- /dev/null +++ b/packages/ludic.shop/package.ludic @@ -0,0 +1,5 @@ +# ludic.shop - vendors that sell what they stock and buy what they take, at prices that move with +# reputation and a weekly demand, and never become a fountain. Uses ludic.base and nothing else. +package "ludic.shop" +version "0.1.0" +kind source diff --git a/packages/ludic.shop/prices.ludic b/packages/ludic.shop/prices.ludic new file mode 100644 index 00000000..cabac49d --- /dev/null +++ b/packages/ludic.shop/prices.ludic @@ -0,0 +1,43 @@ +# prices.ludic - what a thing costs and what it fetches. One standing moves both, toward each +# other and never past: a newcomer pays a little over and sells a little under. +function shop_rep01() -> float { return Math.clamp(float(ShopWorld.rep()), 0.0, 100.0) / 100.0 } + +# the fraction of base a buyer pays, and a seller is paid before the item's own fraction +export function shop_buy_factor() -> float { return shop_buy_new + (shop_buy_best - shop_buy_new) * shop_rep01() } +export function shop_sell_factor() -> float { return shop_sell_new + (shop_sell_best - shop_sell_new) * shop_rep01() } + +export function shop_price_buy(it: int) -> int { + if not shop_item_ok(it) { return 0 } + let p = int(float(shop_base[it]) * shop_buy_factor() * ShopWorld.buy_mult() + 0.5) + return Math.max(p, 1) +} + +# what vendor `v` pays (-1: no vendor, no demand). ROUNDED, so cheap raw material keeps its +# order; and a vendor that stocks the thing never pays what it charges, not even a floor of $1 +export function shop_price_sell_at(v: int, it: int) -> int { + if not shop_item_ok(it) { return 0 } + let f = shop_sell_factor() * shop_sell_frac[it] * shop_demand_mult(v, it) + var p = Math.max(int(float(shop_base[it]) * f + 0.5), 1) + if shop_stocks(v, it) { p = Math.max(Math.min(p, shop_price_buy(it) - 1), 0) } + return p +} + +# what the vendor at the counter pays +export function shop_price_sell(it: int) -> int { return shop_price_sell_at(shop_at, it) } + +export function shop_stocks(v: int, it: int) -> bool { + if not shop_vendor_ok(v) or shop_vendor_list[v].stock == null { return false } + let st = shop_vendor_list[v].stock + for k in 0 .. len(st) { if st[k] == it { return true } } + return false +} + +# what `v` takes: its list and whatever the world says, and never the one thing it refuses +export function shop_buys(v: int, it: int) -> bool { + if not shop_vendor_ok(v) or it == shop_vendor_list[v].refuses { return false } + if ShopWorld.also_buys(v, it) { return true } + let b = shop_vendor_list[v].buys + if b == null { return false } + for k in 0 .. len(b) { if b[k] == it { return true } } + return false +} diff --git a/packages/ludic.shop/state.ludic b/packages/ludic.shop/state.ludic new file mode 100644 index 00000000..31122e1c --- /dev/null +++ b/packages/ludic.shop/state.ludic @@ -0,0 +1,101 @@ +# state.ludic - the ports, the data a game hands over, the numbers it may change, and the facts +# the world a price depends on. Unbound: day one, no shortage, nobody buys beyond their list. +export port ShopWorld { + rep: fn() -> int # the player's standing, 0 to 100 + day: fn() -> int = fn shop_day_one # the game day; the demand turns with the week + buy_mult: fn() -> float = fn shop_one # a shortage on everything bought + also_buys: fn(int, int) -> bool = fn shop_never # (vendor, item) taken beyond the vendor's list +} +function shop_day_one() -> int { return 1 } +function shop_one() -> float { return 1.0 } +function shop_never(v: int, it: int) -> bool { return false } + +export port ShopPurse { + money: fn() -> int + spend: fn(int) -> bool + earn: fn(int) -> void +} + +export port ShopPack { + count: fn(int) -> int + take: fn(int, int) -> bool + add: fn(int, int) -> int + room: fn(int) -> int = fn shop_no_limit +} +function shop_no_limit(it: int) -> int { return 2147483647 } + +# one vendor's shelves: what it sells, what it buys, and one thing it will not take (-1: none) +export property ShopVendor { + stock: []int = null + buys: []int = null + refuses: int = -1 +} + +var shop_base: []int = null # an item's base price +var shop_sell_frac: []float = null # what fraction of the curve a vendor pays for it +var shop_vendor_list: []ShopVendor = null +var shop_at: int = -1 # the vendor at the counter, -1 none + +# the curve: buying from `buy_new` of base at rep 0 down to `buy_best` at 100; selling the same way +var shop_buy_new: float = 1.15 +var shop_buy_best: float = 0.92 +var shop_sell_new: float = 0.62 +var shop_sell_best: float = 0.85 + +# the items: a base price and a sell fraction each (raw material pays less than something made) +export function shop_config_items(prices: []int, sell: []float) -> void { + shop_base = prices + shop_sell_frac = sell +} + +export function shop_config_curve(buy_new: float, buy_best: float, sell_new: float, sell_best: float) -> void { + shop_buy_new = buy_new + shop_buy_best = buy_best + shop_sell_new = sell_new + shop_sell_best = sell_best +} + +export function shop_clear_vendors() -> void { shop_vendor_list = new []ShopVendor } + +# a vendor; its number is the order added +export function shop_add_vendor(stock: []int, buys: []int, refuses: int) -> int { + if shop_vendor_list == null { shop_clear_vendors() } + let v = new ShopVendor + v.stock = stock + v.buys = buys + v.refuses = refuses + push(shop_vendor_list, v) + return len(shop_vendor_list) - 1 +} + +export function shop_vendors() -> int { + if shop_vendor_list == null { return 0 } + return len(shop_vendor_list) +} + +export function shop_items() -> int { + if shop_base == null { return 0 } + return len(shop_base) +} + +export function shop_vendor_ok(v: int) -> bool { return v >= 0 and v < shop_vendors() } +export function shop_item_ok(it: int) -> bool { return it >= 0 and it < shop_items() } + +export const SHOP_BOUGHT: int = 0 +export const SHOP_SOLD: int = 1 + +# something changed hands: `n` of `item` at `price` each +export property ShopTrade { + what: int = 0 + vendor: int = -1 + item: int = 0 + n: int = 0 + price: int = 0 +} + +var shop_trade_q: Queue = null + +export function shop_trades() -> Queue { + if shop_trade_q == null { shop_trade_q = queue_new("shop.trades") } + return shop_trade_q +} diff --git a/packages/ludic.shop/system.ludic b/packages/ludic.shop/system.ludic new file mode 100644 index 00000000..04468ae4 --- /dev/null +++ b/packages/ludic.shop/system.ludic @@ -0,0 +1,13 @@ +# system.ludic - the shop as a system: a reset and nothing saved (the week's demand follows from +# the day, and nobody is at a counter across a save) +export function shop_reset() -> void { + shop_at = -1 + shop_demand_reset() + q_clear(shop_trades()) +} + +export function shop_system() -> System { + let s = system_new("shop", PH_COMMIT) + s.reset = fn shop_reset + return s +} diff --git a/packages/ludic.shop/tests/shop_test.ludic b/packages/ludic.shop/tests/shop_test.ludic new file mode 100644 index 00000000..e5ebc600 --- /dev/null +++ b/packages/ludic.shop/tests/shop_test.ludic @@ -0,0 +1,236 @@ +# shop_test.ludic - a counter over fake money and a fake pack: buying and selling, the week's +# demand from the shop's own dice, and the three balance invariants a price curve must keep: +# a shop is not a fountain, friction falls with standing and never inverts, raw keeps its order +import "ludic.shop" +import "ludic.base" +program ShopTest { + numbers float + const WOOD: int = 0 + const CHANTERELLE: int = 1 + const PUFFBALL: int = 2 + const TROUT: int = 3 + const COOKED: int = 4 + const BAIT: int = 5 + const ROPE: int = 6 + const JACKET: int = 7 + const GRUB: int = 8 + const BURNT: int = 9 + const RAW: float = 0.50 + const MADE: float = 1.15 + const BOUGHT: float = 0.55 + + var rep: int = 0 + var day: int = 1 + var money: int = 0 + var pack: []int = null + var food_too: bool = false + + function fake_rep() -> int { return rep } + function fake_day() -> int { return day } + function fake_food(v: int, it: int) -> bool { return food_too and v == 1 and (it == TROUT or it == COOKED or it == BURNT) } + function fake_money() -> int { return money } + function fake_spend(n: int) -> bool { + if n > money { return false } + money -= n + return true + } + function fake_earn(n: int) -> void { money += n } + function fake_count(it: int) -> int { return pack[it] } + function fake_take(it: int, n: int) -> bool { + if pack[it] < n { return false } + pack[it] = pack[it] - n + return true + } + function fake_add(it: int, n: int) -> int { + pack[it] = pack[it] + n + return n + } + function fake_room(it: int) -> int { + if it == JACKET { return 1 - pack[it] } + return 99 + } + + bind ShopWorld { rep: fn fake_rep, day: fn fake_day, also_buys: fn fake_food } + bind ShopPurse { money: fn fake_money, spend: fn fake_spend, earn: fn fake_earn } + bind ShopPack { count: fn fake_count, take: fn fake_take, add: fn fake_add, room: fn fake_room } + + function ints(a: int, b: int, c: int, d: int) -> []int { + let out = new []int + if a >= 0 { push(out, a) } + if b >= 0 { push(out, b) } + if c >= 0 { push(out, c) } + if d >= 0 { push(out, d) } + return out + } + + # the valley's own numbers: base prices and the sell classes + function fresh() -> void { + let prices = new []int + let sell = new []float + let p = ints(3, 6, 8, 9) + let q = ints(10, 1, 6, 60) + for i in 0 .. 4 { push(prices, p[i]) } + for i in 0 .. 4 { push(prices, q[i]) } + push(prices, 1) + push(prices, 1) + push(sell, RAW) + push(sell, RAW) + push(sell, RAW) + push(sell, RAW) + push(sell, MADE) + push(sell, MADE) + push(sell, BOUGHT) + push(sell, BOUGHT) + push(sell, RAW) + push(sell, RAW) + shop_config_items(prices, sell) + shop_clear_vendors() + shop_add_vendor(ints(ROPE, JACKET, -1, -1), ints(WOOD, CHANTERELLE, -1, -1), -1) + shop_add_vendor(ints(BAIT, COOKED, -1, -1), ints(WOOD, PUFFBALL, BAIT, -1), BURNT) + shop_add_vendor(ints(BAIT, COOKED, GRUB, -1), ints(TROUT, COOKED, BAIT, GRUB), -1) + pack = new []int + for i in 0 .. 10 { push(pack, 0) } + rep = 0 + day = 1 + money = 0 + shop_reset() + } + + function trades() -> []ShopTrade { return q_drain(shop_trades()) } + + test "buying takes the price and puts one in the pack, and says so" { + fresh() + money = 100 + shop_visit(0) + let pr = shop_price_buy(JACKET) + expect(shop_buy(JACKET)) + expect_eq(money, 100 - pr) + expect_eq(pack[JACKET], 1) + expect(not shop_buy(JACKET)) + let ts = trades() + expect_eq(len(ts), 1) + expect_eq(ts[0].what, SHOP_BOUGHT) + expect_eq(ts[0].vendor, 0) + expect_eq(ts[0].price, pr) + } + + test "no money, nothing bought and nothing spent" { + fresh() + money = 3 + expect(not shop_can_buy(JACKET)) + expect(not shop_buy(JACKET)) + expect_eq(money, 3) + expect_eq(len(trades()), 0) + } + + test "selling pays what the counter's vendor pays, only what the pack has" { + fresh() + pack[TROUT] = 3 + shop_visit(2) + let pr = shop_price_sell(TROUT) + expect_eq(shop_sell(TROUT, 5), 3) + expect_eq(money, pr * 3) + expect_eq(pack[TROUT], 0) + expect_eq(trades()[0].n, 3) + } + + test "a vendor buys its list and what the world adds, never what it refuses" { + fresh() + expect(shop_buys(1, PUFFBALL)) + expect(not shop_buys(1, TROUT)) + food_too = true + expect(shop_buys(1, TROUT)) + expect(not shop_buys(1, BURNT)) + pack[BURNT] = 1 + pack[TROUT] = 1 + let s = shop_sellable(1) + expect_eq(len(s), 1) + expect_eq(s[0], TROUT) + shop_visit(1) + expect_eq(shop_sell(BURNT, 1), 0) + } + + test "the week's demand is the shop's own dice, and turns with the week" { + fresh() + let a = shop_wanted(0) + expect(len(a) > 0) + for k in 0 .. len(a) { expect(shop_buys(0, a[k])) } + let again = shop_wanted(0) + expect_eq(again[0], a[0]) + shop_visit(0) + let it = a[0] + let plain = shop_price_sell_at(-1, it) + expect(shop_demand_mult(0, it) == 1.5) + expect(shop_price_sell(it) >= plain) + var moved = false + for w in 1 .. 12 { + day = 1 + w * 7 + let b = shop_wanted(0) + if len(b) != len(a) or b[0] != a[0] { moved = true } + } + expect(moved) + } + + test "a shop is not a fountain: nothing it stocks sells back for what it costs" { + fresh() + for v in 0 .. shop_vendors() { + let st = shop_stock(v) + for k in 0 .. len(st) { + for r in 0 .. 11 { + rep = r * 10 + shop_set_want(v, st[k]) + let buy = shop_price_buy(st[k]) + let sell = shop_price_sell_at(v, st[k]) + expect(sell < buy) + expect(sell >= 0) + } + } + } + } + + test "not even at a dollar: a penny item a vendor stocks fetches nothing" { + fresh() + rep = 100 + shop_set_want(2, GRUB) + expect_eq(shop_price_buy(GRUB), 1) + expect_eq(shop_price_sell_at(2, GRUB), 0) + expect(shop_price_sell_at(-1, GRUB) >= 1) + } + + test "friction falls with standing and never inverts" { + fresh() + var last_buy = 1000000 + var last_sell = -1 + for r in 0 .. 11 { + rep = r * 10 + expect(shop_buy_factor() > shop_sell_factor()) + let b = shop_price_buy(JACKET) + let s = shop_price_sell_at(-1, COOKED) + expect(b <= last_buy) + expect(s >= last_sell) + last_buy = b + last_sell = s + } + rep = 0 + let b0 = shop_price_buy(JACKET) + rep = 100 + expect(shop_price_buy(JACKET) < b0) + rep = 0 + expect(float(b0) / 60.0 <= 1.25) + } + + test "raw material keeps its order, and is not an income" { + fresh() + for r in 0 .. 11 { + rep = r * 10 + let w = shop_price_sell_at(-1, WOOD) + let ch = shop_price_sell_at(-1, CHANTERELLE) + let pf = shop_price_sell_at(-1, PUFFBALL) + let tr = shop_price_sell_at(-1, TROUT) + expect(w <= ch and ch <= pf and pf <= tr) + expect(pf <= 8 / 2) + } + rep = 0 + expect(shop_price_sell_at(-1, WOOD) < shop_price_sell_at(-1, TROUT)) + } +} diff --git a/packages/ludic.shop/trade.ludic b/packages/ludic.shop/trade.ludic new file mode 100644 index 00000000..a7d49626 --- /dev/null +++ b/packages/ludic.shop/trade.ludic @@ -0,0 +1,63 @@ +# trade.ludic - standing at a counter, and the only ways anything changes hands +export function shop_visit(v: int) -> void { + if shop_vendor_ok(v) { shop_at = v } else { shop_at = -1 } +} + +export function shop_away() -> void { shop_at = -1 } + +# the vendor at the counter, -1 none +export function shop_vendor() -> int { return shop_at } + +export function shop_at_counter() -> bool { return shop_at >= 0 } + +# what `v` has on its shelves, in shelf order +export function shop_stock(v: int) -> []int { + let out = new []int + if not shop_vendor_ok(v) or shop_vendor_list[v].stock == null { return out } + let st = shop_vendor_list[v].stock + for k in 0 .. len(st) { push(out, st[k]) } + return out +} + +# what in the pack `v` would buy +export function shop_sellable(v: int) -> []int { + let out = new []int + for it in 0 .. shop_items() { if ShopPack.count(it) > 0 and shop_buys(v, it) { push(out, it) } } + return out +} + +function shop_trade_fact(what: int, it: int, n: int, price: int) -> void { + let t = new ShopTrade + t.what = what + t.vendor = shop_at + t.item = it + t.n = n + t.price = price + q_push(shop_trades(), t) +} + +export function shop_can_buy(it: int) -> bool { + return shop_item_ok(it) and ShopPurse.money() >= shop_price_buy(it) and ShopPack.room(it) > 0 +} + +# one, paid for and in the pack; false and nothing spent when it cannot be +export function shop_buy(it: int) -> bool { + if not shop_can_buy(it) { return false } + let pr = shop_price_buy(it) + if not ShopPurse.spend(pr) { return false } + ShopPack.add(it, 1) + shop_trade_fact(SHOP_BOUGHT, it, 1, pr) + return true +} + +# `n` to the vendor at the counter (as many as the pack has); how many went +export function shop_sell(it: int, n: int) -> int { + if not shop_buys(shop_at, it) { return 0 } + let k = Math.min(n, ShopPack.count(it)) + if k <= 0 { return 0 } + let pr = shop_price_sell(it) + if not ShopPack.take(it, k) { return 0 } + ShopPurse.earn(pr * k) + shop_trade_fact(SHOP_SOLD, it, k, pr) + return k +}