Merge feat/currency into dev
Money becomes copper-based with real denominations (1g = 100s = 10,000c), stored as one integer and formatted only at the display edge. Money is not an inventory item: give_item/accept_item carry no quantity and gifts_given is a global one-shot, so a single 'copper' id would be givable once, for one copper, per campaign. The denominations are move tokens instead — give_item(gold) is +10,000c — and currency routes to the purse, never the inventory dict, so it cannot surface in an inventory grid by construction. Prices remain content and remain M8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
This commit is contained in:
@@ -154,10 +154,10 @@ layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "MP 18 / 30"
|
||||
|
||||
[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||
[node name="Purse" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "◈ 1240 gp"
|
||||
text = "◈ 3s 47c"
|
||||
|
||||
[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"]
|
||||
layout_mode = 2
|
||||
|
||||
@@ -61,9 +61,10 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
var qd: Dictionary = world.quest(quest_id)
|
||||
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", "")))
|
||||
|
||||
# inventory grants -> game state (NOT the log). Charter §2.
|
||||
# inventory grants -> game state (NOT the log). Charter §2. grant() routes
|
||||
# currency to the purse; everything else to the inventory dict.
|
||||
for grant in origin.get("inventory_grants", []):
|
||||
state.add_item(grant.get("item_id", ""), int(grant.get("qty", 0)))
|
||||
state.grant(grant.get("item_id", ""), int(grant.get("qty", 0)))
|
||||
|
||||
return {"ok": true, "errors": [], "log": log, "state": state}
|
||||
|
||||
|
||||
@@ -11,9 +11,20 @@ const HOSTILE_FLOOR := -100
|
||||
|
||||
|
||||
static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
|
||||
# A currency move (give_item/accept_item on a denomination) applies AT MOST
|
||||
# ONCE per reply. Without this, a model repeating one tag N times would pick
|
||||
# an arbitrary amount in unary — the AI choosing a number is a §2 breach.
|
||||
# Real-item duplicates are unaffected; that stays bounded by gifts_given.
|
||||
var currency_applied_this_reply := {} # "name(denom)" -> true
|
||||
for move in moves:
|
||||
var name: String = move.get("name", "")
|
||||
var args: Array = move.get("args", [])
|
||||
if name in ["give_item", "accept_item"] and not args.is_empty() \
|
||||
and Currency.is_currency(str(args[0])):
|
||||
var currency_key := "%s(%s)" % [name, str(args[0])]
|
||||
if currency_applied_this_reply.has(currency_key):
|
||||
continue
|
||||
currency_applied_this_reply[currency_key] = true
|
||||
match name:
|
||||
"offer_quest":
|
||||
var q: String = str(args[0])
|
||||
@@ -23,10 +34,11 @@ static func apply(moves: Array, game_state, canon_log, content_db, npc_id: Strin
|
||||
game_state.mark_revealed(str(args[0]))
|
||||
"give_item":
|
||||
var i: String = str(args[0])
|
||||
game_state.add_item(i, 1)
|
||||
game_state.mark_gift_given(i)
|
||||
game_state.grant(i, 1)
|
||||
if not Currency.is_currency(i):
|
||||
game_state.mark_gift_given(i) # the one-shot gate is for real items only
|
||||
"accept_item":
|
||||
game_state.remove_item(str(args[0]), 1)
|
||||
game_state.take(str(args[0]), 1)
|
||||
"adjust_disposition":
|
||||
var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
|
||||
var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))
|
||||
|
||||
@@ -21,12 +21,20 @@ static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Arra
|
||||
moves.append("reveal(%s)" % t)
|
||||
|
||||
for i in caps.get("giveable_items", []):
|
||||
if not game_state.is_gift_given(i):
|
||||
# Currency is exempt from the one-shot gift gate — an NPC can pay you twice.
|
||||
if Currency.is_currency(i) or not game_state.is_gift_given(i):
|
||||
moves.append("give_item(%s)" % i)
|
||||
|
||||
for item_id in game_state.inventory.keys():
|
||||
if int(game_state.inventory[item_id]) > 0:
|
||||
moves.append("accept_item(%s)" % item_id)
|
||||
|
||||
# Money is not in the inventory dict, so it needs its own offer. Only the
|
||||
# denominations the purse can actually pay: an NPC cannot ask a party holding
|
||||
# 47c for a gold coin.
|
||||
for denom in Currency.VALUES:
|
||||
if game_state.purse_copper >= Currency.value(denom):
|
||||
moves.append("accept_item(%s)" % denom)
|
||||
|
||||
moves.append_array(UNIVERSAL)
|
||||
return moves
|
||||
|
||||
44
client/scripts/state/currency.gd
Normal file
44
client/scripts/state/currency.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
class_name Currency
|
||||
extends RefCounted
|
||||
## Money. Everything is stored in COPPER — one integer, on GameState.purse_copper.
|
||||
## The denominations exist so a bounded §6 move can name a unit (give_item(gold)
|
||||
## is +10,000c); they are NOT stored buckets, so change-making never arises.
|
||||
##
|
||||
## The ratios are rules and live here in code. PRICES are content and live in
|
||||
## /content (roadmap: "the economy is two systems"). Formatting happens only at
|
||||
## the display edge — this is the one place that knows what "12g 40s 8c" means.
|
||||
|
||||
const COPPER := 1
|
||||
const SILVER := 100
|
||||
const GOLD := 10000
|
||||
|
||||
const VALUES := {"copper": COPPER, "silver": SILVER, "gold": GOLD}
|
||||
|
||||
|
||||
static func is_currency(item_id: String) -> bool:
|
||||
return VALUES.has(item_id)
|
||||
|
||||
|
||||
static func value(item_id: String) -> int:
|
||||
return int(VALUES.get(item_id, 0))
|
||||
|
||||
|
||||
static func format(copper: int) -> String:
|
||||
# Empty denominations are suppressed and at least one unit always shows:
|
||||
# "0c", "47c", "1g 8c", "12g 40s". "0g 0s 8c" reads as a spreadsheet, not a
|
||||
# purse — and being broke should read as poverty (§3).
|
||||
if copper <= 0:
|
||||
return "0c"
|
||||
var parts: Array[String] = []
|
||||
var left := copper
|
||||
var gold := left / GOLD # int division
|
||||
if gold > 0:
|
||||
parts.append("%dg" % gold)
|
||||
left -= gold * GOLD
|
||||
var silver := left / SILVER
|
||||
if silver > 0:
|
||||
parts.append("%ds" % silver)
|
||||
left -= silver * SILVER
|
||||
if left > 0:
|
||||
parts.append("%dc" % left)
|
||||
return " ".join(parts)
|
||||
1
client/scripts/state/currency.gd.uid
Normal file
1
client/scripts/state/currency.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cwnflbwacp5jf
|
||||
@@ -8,7 +8,8 @@ var luck: int = 0
|
||||
var luck_base: int = 0
|
||||
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
||||
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
||||
var inventory: Dictionary = {} # item_id -> qty
|
||||
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
|
||||
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).
|
||||
var revealed_topics: Dictionary = {} # topic_id -> true
|
||||
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
|
||||
|
||||
@@ -41,6 +42,27 @@ func remove_item(item_id: String, qty: int) -> void:
|
||||
inventory.erase(item_id)
|
||||
|
||||
|
||||
func add_copper(amount: int) -> void:
|
||||
purse_copper = maxi(0, purse_copper + amount)
|
||||
|
||||
|
||||
func grant(item_id: String, qty: int) -> void:
|
||||
# The single routing point (with take): currency lands in the purse, every
|
||||
# other item in the inventory dict. Money is never an inventory row, so it
|
||||
# can never surface in an inventory grid — exclusion by construction.
|
||||
if Currency.is_currency(item_id):
|
||||
add_copper(Currency.value(item_id) * qty)
|
||||
else:
|
||||
add_item(item_id, qty)
|
||||
|
||||
|
||||
func take(item_id: String, qty: int) -> void:
|
||||
if Currency.is_currency(item_id):
|
||||
add_copper(-Currency.value(item_id) * qty)
|
||||
else:
|
||||
remove_item(item_id, qty)
|
||||
|
||||
|
||||
func mark_revealed(topic_id: String) -> void:
|
||||
revealed_topics[topic_id] = true
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ var _http: HTTPRequest
|
||||
@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens
|
||||
@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP
|
||||
@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP
|
||||
@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold
|
||||
@onready var _purse: Label = $Split/World/CommandBar/Vitals/Purse
|
||||
@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables
|
||||
|
||||
|
||||
@@ -93,8 +93,8 @@ func _side_color(e: TurnEntry) -> Color:
|
||||
func _bind_vitals() -> void:
|
||||
_hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
|
||||
_mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
|
||||
_gold.text = "◈ %d gp" % _state.vitals["gold"]
|
||||
_gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
|
||||
_purse.text = "◈ %s" % Currency.format(_state.vitals["purse_copper"])
|
||||
_purse.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
|
||||
|
||||
|
||||
func _bind_consumables() -> void:
|
||||
|
||||
@@ -4,7 +4,7 @@ extends RefCounted
|
||||
## 2a; later systems (combat vitals, inventory consumables) become the writers.
|
||||
## No Luck/LCK value lives here — it is never surfaced (§7).
|
||||
|
||||
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0}
|
||||
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "purse_copper": 0}
|
||||
var turn_order: Array[TurnEntry] = []
|
||||
var consumables: Array = []
|
||||
var round_label: String = ""
|
||||
@@ -19,7 +19,7 @@ func toggle_dock() -> bool:
|
||||
|
||||
static func seed() -> ShellState:
|
||||
var s := ShellState.new()
|
||||
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240}
|
||||
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "purse_copper": 347}
|
||||
s.turn_order = [
|
||||
TurnEntry.new("EL", 17, &"you", true, false),
|
||||
TurnEntry.new("DW", 12, &"ally"),
|
||||
|
||||
@@ -18,7 +18,24 @@ func test_loads_world_ids():
|
||||
assert_true(db.has_location("greywater_docks"))
|
||||
assert_true(db.has_quest("find_the_ledger"))
|
||||
assert_true(db.has_item("worn_shortsword"))
|
||||
assert_true(db.has_item("coin"))
|
||||
assert_true(db.has_item("copper"))
|
||||
assert_true(db.has_item("silver"))
|
||||
assert_true(db.has_item("gold"))
|
||||
assert_false(db.has_item("coin"), "the undifferentiated coin is gone")
|
||||
|
||||
|
||||
func test_currency_items_agree_with_the_code():
|
||||
# The denomination ids exist twice — in code (Currency.VALUES, because the
|
||||
# ratios are rules) and in content (slot: "currency"). Guard the two against
|
||||
# drifting apart, since nothing at runtime reconciles them.
|
||||
var content_ids: Array = []
|
||||
for id in db.items:
|
||||
if db.items[id].get("slot", "") == "currency":
|
||||
content_ids.append(id)
|
||||
content_ids.sort()
|
||||
var code_ids: Array = Currency.VALUES.keys()
|
||||
code_ids.sort()
|
||||
assert_eq(content_ids, code_ids)
|
||||
|
||||
|
||||
func test_companions_are_brannoc_and_cadwyn():
|
||||
|
||||
42
client/tests/unit/test_currency.gd
Normal file
42
client/tests/unit/test_currency.gd
Normal file
@@ -0,0 +1,42 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
|
||||
func test_format_zero_is_copper():
|
||||
assert_eq(Currency.format(0), "0c")
|
||||
|
||||
|
||||
func test_format_sub_silver():
|
||||
assert_eq(Currency.format(8), "8c")
|
||||
assert_eq(Currency.format(47), "47c")
|
||||
|
||||
|
||||
func test_format_exact_denominations():
|
||||
assert_eq(Currency.format(100), "1s")
|
||||
assert_eq(Currency.format(10000), "1g")
|
||||
|
||||
|
||||
func test_format_full_ladder():
|
||||
assert_eq(Currency.format(143), "1s 43c")
|
||||
assert_eq(Currency.format(347), "3s 47c")
|
||||
assert_eq(Currency.format(124008), "12g 40s 8c")
|
||||
|
||||
|
||||
func test_format_skips_empty_middle_and_tail():
|
||||
assert_eq(Currency.format(10008), "1g 8c", "an empty silver place is skipped, not zero-padded")
|
||||
assert_eq(Currency.format(124000), "12g 40s", "an empty copper place is skipped")
|
||||
|
||||
|
||||
func test_value_of_each_denomination():
|
||||
assert_eq(Currency.value("copper"), 1)
|
||||
assert_eq(Currency.value("silver"), 100)
|
||||
assert_eq(Currency.value("gold"), 10000)
|
||||
|
||||
|
||||
func test_non_currency_id_is_not_money():
|
||||
assert_false(Currency.is_currency("worn_shortsword"))
|
||||
assert_eq(Currency.value("worn_shortsword"), 0)
|
||||
|
||||
|
||||
func test_currency_ids_are_currency():
|
||||
for id in ["copper", "silver", "gold"]:
|
||||
assert_true(Currency.is_currency(id), "%s is money" % id)
|
||||
1
client/tests/unit/test_currency.gd.uid
Normal file
1
client/tests/unit/test_currency.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dwpdm680q83ws
|
||||
@@ -5,9 +5,9 @@ const GameState = preload("res://scripts/state/game_state.gd")
|
||||
|
||||
func test_add_item_accumulates():
|
||||
var s = GameState.new()
|
||||
s.add_item("coin", 3)
|
||||
s.add_item("coin", 2)
|
||||
assert_eq(s.inventory["coin"], 5)
|
||||
s.add_item("worn_shortsword", 3)
|
||||
s.add_item("worn_shortsword", 2)
|
||||
assert_eq(s.inventory["worn_shortsword"], 5)
|
||||
|
||||
|
||||
func test_npc_disposition_clamps():
|
||||
@@ -32,11 +32,11 @@ func test_luck_descriptor_delegates():
|
||||
|
||||
func test_remove_item_decrements_and_floors_at_zero():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("coin", 3)
|
||||
gs.remove_item("coin", 2)
|
||||
assert_eq(gs.inventory.get("coin", 0), 1)
|
||||
gs.remove_item("coin", 5)
|
||||
assert_eq(gs.inventory.get("coin", 0), 0)
|
||||
gs.add_item("worn_shortsword", 3)
|
||||
gs.remove_item("worn_shortsword", 2)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
gs.remove_item("worn_shortsword", 5)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 0)
|
||||
|
||||
|
||||
func test_revealed_topics_tracked():
|
||||
@@ -51,3 +51,42 @@ func test_gifts_given_tracked():
|
||||
assert_false(gs.is_gift_given("amulet"))
|
||||
gs.mark_gift_given("amulet")
|
||||
assert_true(gs.is_gift_given("amulet"))
|
||||
|
||||
|
||||
func test_purse_starts_empty():
|
||||
assert_eq(GameState.new().purse_copper, 0)
|
||||
|
||||
|
||||
func test_grant_currency_lands_in_the_purse_not_the_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.grant("silver", 2)
|
||||
assert_eq(gs.purse_copper, 200)
|
||||
assert_eq(gs.inventory.size(), 0, "money is never an inventory row")
|
||||
|
||||
|
||||
func test_grant_non_currency_lands_in_the_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.grant("worn_shortsword", 1)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
assert_eq(gs.purse_copper, 0)
|
||||
|
||||
|
||||
func test_take_currency_spends_from_the_purse():
|
||||
var gs = GameState.new()
|
||||
gs.grant("gold", 1)
|
||||
gs.take("copper", 5)
|
||||
assert_eq(gs.purse_copper, 9995)
|
||||
|
||||
|
||||
func test_take_currency_clamps_at_zero():
|
||||
var gs = GameState.new()
|
||||
gs.grant("copper", 47)
|
||||
gs.take("gold", 1)
|
||||
assert_eq(gs.purse_copper, 0, "the purse never goes negative")
|
||||
|
||||
|
||||
func test_take_non_currency_removes_from_the_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.grant("worn_shortsword", 2)
|
||||
gs.take("worn_shortsword", 1)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
|
||||
@@ -44,12 +44,118 @@ func test_become_hostile_sets_floor():
|
||||
|
||||
func test_accept_item_removes_from_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("coin", 2)
|
||||
MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("coin", 0), 1)
|
||||
gs.add_item("worn_shortsword", 2)
|
||||
MoveApplier.apply([_m("accept_item", ["worn_shortsword"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
|
||||
|
||||
func test_give_item_currency_pays_the_purse():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 10000)
|
||||
assert_eq(gs.inventory.size(), 0, "money is never an inventory row")
|
||||
|
||||
|
||||
func test_give_item_currency_is_not_a_one_shot_gift():
|
||||
# gifts_given is a GLOBAL one-shot keyed by item id. If money were marked, an
|
||||
# NPC could hand over coin exactly once, ever, for the whole campaign.
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 20000)
|
||||
assert_false(gs.is_gift_given("gold"), "currency is exempt from the one-shot gate")
|
||||
|
||||
|
||||
func test_give_item_real_item_still_marks_the_one_shot_gate():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["amulet"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("amulet", 0), 1)
|
||||
assert_true(gs.is_gift_given("amulet"))
|
||||
|
||||
|
||||
func test_accept_item_currency_spends_the_purse():
|
||||
var gs = GameState.new()
|
||||
gs.grant("gold", 1)
|
||||
MoveApplier.apply([_m("accept_item", ["silver"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 9900)
|
||||
|
||||
|
||||
func test_refuse_and_end_are_noops():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.npc_dispositions.size(), 0)
|
||||
|
||||
|
||||
# --- Finding 1: a repeated currency move tag in ONE reply must not let the AI
|
||||
# pick an arbitrary amount by unary repetition (§2 breach). A currency move
|
||||
# (give_item/accept_item on a denomination) applies AT MOST ONCE per apply()
|
||||
# call, keyed on move name + denomination.
|
||||
|
||||
func test_repeated_accept_item_silver_applies_once():
|
||||
var gs = GameState.new()
|
||||
gs.purse_copper = 10000
|
||||
MoveApplier.apply([_m("accept_item", ["silver"]), _m("accept_item", ["silver"])],
|
||||
gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 9900, "second accept_item(silver) tag must be a no-op")
|
||||
|
||||
|
||||
func test_repeated_give_item_gold_applies_once():
|
||||
var gs = GameState.new()
|
||||
var moves: Array = []
|
||||
for i in range(10):
|
||||
moves.append(_m("give_item", ["gold"]))
|
||||
MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 10000, "nine of the ten give_item(gold) tags must be no-ops")
|
||||
|
||||
|
||||
func test_repeated_accept_item_copper_applies_once():
|
||||
var gs = GameState.new()
|
||||
gs.purse_copper = 47
|
||||
var moves: Array = []
|
||||
for i in range(47):
|
||||
moves.append(_m("accept_item", ["copper"]))
|
||||
MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 46, "46 of the 47 accept_item(copper) tags must be no-ops")
|
||||
|
||||
|
||||
func test_different_currency_moves_both_still_apply():
|
||||
# Proves dedup is keyed per (move name + denomination), not per reply as a whole.
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["silver"])],
|
||||
gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 9900)
|
||||
|
||||
|
||||
func test_dedup_key_is_denomination_not_just_move_name():
|
||||
# Two DIFFERENT denominations under the same move name must both land. A key
|
||||
# of "give_item" alone would swallow the silver.
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["gold"]), _m("give_item", ["silver"])],
|
||||
gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 10100)
|
||||
|
||||
|
||||
func test_dedup_key_is_move_name_not_just_denomination():
|
||||
# The same denomination under two DIFFERENT move names must both land. A key
|
||||
# of "gold" alone would swallow the accept, leaving 10000 instead of 0.
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["gold"])],
|
||||
gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 0)
|
||||
|
||||
|
||||
func test_repeated_non_currency_give_item_still_applies_each_time():
|
||||
# Pins the scope line: real-item duplicate handling is UNCHANGED by this fix
|
||||
# (it stays bounded only by the cross-reply gifts_given gate, reported separately).
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["amulet"]), _m("give_item", ["amulet"]), _m("give_item", ["amulet"])],
|
||||
gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("amulet", 0), 3, "non-currency give_item is out of scope for this fix")
|
||||
|
||||
|
||||
func test_repeated_non_currency_accept_item_still_applies_each_time():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("worn_shortsword", 5)
|
||||
MoveApplier.apply([_m("accept_item", ["worn_shortsword"]), _m("accept_item", ["worn_shortsword"])],
|
||||
gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 3, "non-currency accept_item is out of scope for this fix")
|
||||
|
||||
@@ -40,8 +40,9 @@ func test_numeric_luck_absent_from_log():
|
||||
|
||||
func test_inventory_and_dispo_land_in_state_not_log():
|
||||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||||
assert_eq(res["state"].inventory.get("coin", 0), 3)
|
||||
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
|
||||
assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin")
|
||||
assert_false("copper" in res["state"].inventory, "money is never an inventory row")
|
||||
assert_false("inventory" in res["log"].to_dict())
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,54 @@ func test_revealed_topic_drops_out():
|
||||
|
||||
func test_accept_item_offered_per_held_item():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("coin", 2)
|
||||
gs.add_item("worn_shortsword", 2)
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_true("accept_item(coin)" in moves)
|
||||
assert_true("accept_item(worn_shortsword)" in moves)
|
||||
|
||||
|
||||
func test_a_broke_party_is_asked_for_nothing():
|
||||
var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
|
||||
for denom in ["copper", "silver", "gold"]:
|
||||
assert_false("accept_item(%s)" % denom in moves, "an empty purse cannot pay a %s" % denom)
|
||||
|
||||
|
||||
func test_accept_item_denomination_gated_on_affordability():
|
||||
var gs = GameState.new()
|
||||
gs.grant("copper", 347)
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_true("accept_item(copper)" in moves)
|
||||
assert_true("accept_item(silver)" in moves)
|
||||
assert_false("accept_item(gold)" in moves, "347c cannot pay a gold coin")
|
||||
|
||||
|
||||
func test_a_gold_coin_is_asked_for_once_affordable():
|
||||
var gs = GameState.new()
|
||||
gs.grant("gold", 1)
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_true("accept_item(gold)" in moves)
|
||||
|
||||
|
||||
func test_currency_never_arrives_via_the_inventory_loop():
|
||||
# §7 of the currency spec: currency never enters the inventory dict, so the
|
||||
# only accept_item(<denom>) in available_moves comes from the affordability
|
||||
# loop — never a double-offer via the inventory.keys() loop.
|
||||
var gs = GameState.new()
|
||||
gs.grant("gold", 1)
|
||||
assert_true(gs.inventory.is_empty(), "currency must never be written into inventory")
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
var gold_offers := 0
|
||||
for m in moves:
|
||||
if m == "accept_item(gold)":
|
||||
gold_offers += 1
|
||||
assert_eq(gold_offers, 1, "accept_item(gold) must be offered exactly once")
|
||||
|
||||
|
||||
func test_currency_gift_is_offered_repeatedly():
|
||||
var npc := _fenn()
|
||||
npc["capabilities"]["giveable_items"] = ["copper", "amulet"]
|
||||
var gs = GameState.new()
|
||||
gs.mark_gift_given("copper")
|
||||
gs.mark_gift_given("amulet")
|
||||
var moves = NpcContent.available_moves(npc, gs, CanonLog.new())
|
||||
assert_true("give_item(copper)" in moves, "money is not a one-shot gift")
|
||||
assert_false("give_item(amulet)" in moves, "a real item still is")
|
||||
|
||||
@@ -16,7 +16,13 @@ func test_seed_vitals():
|
||||
assert_eq(s.vitals["hp_max"], 60)
|
||||
assert_eq(s.vitals["mp"], 18)
|
||||
assert_eq(s.vitals["mp_max"], 30)
|
||||
assert_eq(s.vitals["gold"], 1240)
|
||||
assert_eq(s.vitals["purse_copper"], 347)
|
||||
assert_false("gold" in s.vitals, "the flat gold placeholder is gone")
|
||||
|
||||
|
||||
func test_seed_purse_renders_as_denominations():
|
||||
var s := ShellState.seed()
|
||||
assert_eq(Currency.format(s.vitals["purse_copper"]), "3s 47c")
|
||||
|
||||
|
||||
func test_seed_turn_order():
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||
"inventory_grants": [
|
||||
{ "item_id": "worn_shortsword", "qty": 1 },
|
||||
{ "item_id": "coin", "qty": 3 }
|
||||
{ "item_id": "copper", "qty": 47 }
|
||||
],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{ "id": "coin", "name": "coin", "slot": "currency" }
|
||||
1
content/world/items/copper.json
Normal file
1
content/world/items/copper.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "id": "copper", "name": "copper coin", "slot": "currency" }
|
||||
1
content/world/items/gold.json
Normal file
1
content/world/items/gold.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "id": "gold", "name": "gold coin", "slot": "currency" }
|
||||
1
content/world/items/silver.json
Normal file
1
content/world/items/silver.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "id": "silver", "name": "silver coin", "slot": "currency" }
|
||||
@@ -196,6 +196,25 @@ func take(item_id: String, qty: int) -> void # currency -> purse_copper; else
|
||||
`take` clamps the purse at `0`. The validator already gates affordability (§4.7); the
|
||||
clamp is defence in depth, not the mechanism.
|
||||
|
||||
**A currency move applies at most once per reply.** `TagExtractor` returns *every* tag
|
||||
occurrence, `MoveValidator` is a pure membership test, and `MoveApplier` loops — so
|
||||
without this, a model repeating `[MOVE: accept_item(copper)]` forty-seven times would
|
||||
drain a 47c purse, and ten `give_item(gold)` tags would mint 100,000c. That is the model
|
||||
choosing an amount **in unary** — the §2 breach that rejecting a quantity argument was
|
||||
meant to prevent. `MoveApplier` therefore keys currency moves on `name(denomination)`
|
||||
and drops repeats within a reply.
|
||||
|
||||
The residual ceiling is deliberate: one reply may still combine *distinct* denominations
|
||||
(`give_item(gold)` + `give_item(silver)` + `give_item(copper)` = +10,101c). That is a
|
||||
model selecting a subset of a closed, six-element, state-gated set — an NPC handing over
|
||||
a gold, a silver and a copper is a legal sentence — not inventing an integer. Unary
|
||||
repetition was what made it unbounded, and that is closed.
|
||||
|
||||
**The same duplicate-tag path exists for non-currency moves and predates this work**
|
||||
(three `give_item(amulet)` tags yield three amulets; a repeated `offer_quest` can
|
||||
double-add a quest). It is bounded *across* replies by `gifts_given` / `has_quest`, but
|
||||
not *within* one. Deliberately left alone here — it is not a currency bug. **Open.**
|
||||
|
||||
**`NpcContent.available_moves`:**
|
||||
|
||||
- `accept_item(<denom>)` is offered for each denomination the player can actually
|
||||
|
||||
Reference in New Issue
Block a user