From 0a9742a984afb7245a7e20f8445ad3591b884f4a Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 14:24:59 -0500 Subject: [PATCH] feat(client): Luck (deterministic gen/drift/descriptor) + GameState store Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/state/game_state.gd | 31 ++++++++++++++++++++ client/scripts/state/game_state.gd.uid | 1 + client/scripts/state/luck.gd | 35 +++++++++++++++++++++++ client/scripts/state/luck.gd.uid | 1 + client/tests/unit/test_game_state.gd | 30 ++++++++++++++++++++ client/tests/unit/test_game_state.gd.uid | 1 + client/tests/unit/test_luck.gd | 36 ++++++++++++++++++++++++ client/tests/unit/test_luck.gd.uid | 1 + 8 files changed, 136 insertions(+) create mode 100644 client/scripts/state/game_state.gd create mode 100644 client/scripts/state/game_state.gd.uid create mode 100644 client/scripts/state/luck.gd create mode 100644 client/scripts/state/luck.gd.uid create mode 100644 client/tests/unit/test_game_state.gd create mode 100644 client/tests/unit/test_game_state.gd.uid create mode 100644 client/tests/unit/test_luck.gd create mode 100644 client/tests/unit/test_luck.gd.uid diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd new file mode 100644 index 0000000..2ee7b06 --- /dev/null +++ b/client/scripts/state/game_state.gd @@ -0,0 +1,31 @@ +class_name GameState +extends RefCounted +## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck, +## stats, world-NPC dispositions, and inventory — none of which enter the canon +## log (charter §7/§2). The log reads only luck_descriptor() from here. + +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 + + +func set_luck(v: int) -> void: + luck = clampi(v, Luck.MIN, Luck.MAX) + + +func drift_luck(delta: int) -> void: + luck = Luck.drift(luck, luck_base, delta) + + +func luck_descriptor() -> String: + return Luck.descriptor(luck) + + +func set_npc_disposition(id: String, v: int) -> void: + npc_dispositions[id] = clampi(v, -100, 100) + + +func add_item(item_id: String, qty: int) -> void: + inventory[item_id] = int(inventory.get(item_id, 0)) + qty diff --git a/client/scripts/state/game_state.gd.uid b/client/scripts/state/game_state.gd.uid new file mode 100644 index 0000000..dcb1065 --- /dev/null +++ b/client/scripts/state/game_state.gd.uid @@ -0,0 +1 @@ +uid://c0ffensw2ym6w diff --git a/client/scripts/state/luck.gd b/client/scripts/state/luck.gd new file mode 100644 index 0000000..1f557f3 --- /dev/null +++ b/client/scripts/state/luck.gd @@ -0,0 +1,35 @@ +class_name Luck +extends RefCounted +## Numeric Luck (charter §7). Lives in GameState; only descriptor() reaches the +## log. Deterministic given a seeded RNG (charter §10). Die/range/bands are +## TUNABLE constants — not final values. + +const MIN := 1 +const MAX := 20 +const DRIFT := 3 + +const BANDS := [ + {"max": 4, "text": "Fortune spits on you"}, + {"max": 8, "text": "The world is not on your side"}, + {"max": 12, "text": "The dice are indifferent"}, + {"max": 16, "text": "Luck rides with you"}, + {"max": 20, "text": "The dice are kind today"}, +] + + +static func roll_base(rng: RandomNumberGenerator, modifier: int) -> int: + var total := 0 + for i in 5: + total += rng.randi_range(1, 20) + return clampi(roundi(total / 5.0) + modifier, MIN, MAX) + + +static func drift(current: int, base: int, delta: int) -> int: + return clampi(clampi(current + delta, base - DRIFT, base + DRIFT), MIN, MAX) + + +static func descriptor(luck: int) -> String: + for band in BANDS: + if luck <= band["max"]: + return band["text"] + return BANDS[-1]["text"] diff --git a/client/scripts/state/luck.gd.uid b/client/scripts/state/luck.gd.uid new file mode 100644 index 0000000..d1408ba --- /dev/null +++ b/client/scripts/state/luck.gd.uid @@ -0,0 +1 @@ +uid://dah05dv3uoym diff --git a/client/tests/unit/test_game_state.gd b/client/tests/unit/test_game_state.gd new file mode 100644 index 0000000..e83917b --- /dev/null +++ b/client/tests/unit/test_game_state.gd @@ -0,0 +1,30 @@ +extends "res://addons/gut/test.gd" + +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) + + +func test_npc_disposition_clamps(): + var s = GameState.new() + s.set_npc_disposition("oda_fenn", 250) + assert_eq(s.npc_dispositions["oda_fenn"], 100) + + +func test_drift_luck_respects_base(): + var s = GameState.new() + s.luck_base = 10 + s.luck = 10 + s.drift_luck(100) + assert_eq(s.luck, 13) + + +func test_luck_descriptor_delegates(): + var s = GameState.new() + s.luck = 4 + assert_eq(s.luck_descriptor(), "Fortune spits on you") diff --git a/client/tests/unit/test_game_state.gd.uid b/client/tests/unit/test_game_state.gd.uid new file mode 100644 index 0000000..c34bfac --- /dev/null +++ b/client/tests/unit/test_game_state.gd.uid @@ -0,0 +1 @@ +uid://chxglv8kbep2i diff --git a/client/tests/unit/test_luck.gd b/client/tests/unit/test_luck.gd new file mode 100644 index 0000000..17f1b37 --- /dev/null +++ b/client/tests/unit/test_luck.gd @@ -0,0 +1,36 @@ +extends "res://addons/gut/test.gd" + +const Luck = preload("res://scripts/state/luck.gd") + + +func _rng(s: int) -> RandomNumberGenerator: + var r := RandomNumberGenerator.new() + r.seed = s + return r + + +func test_roll_base_is_deterministic_for_a_seed(): + assert_eq(Luck.roll_base(_rng(42), 0), Luck.roll_base(_rng(42), 0)) + + +func test_roll_base_stays_in_range(): + for s in range(1, 30): + var v := Luck.roll_base(_rng(s), 0) + assert_true(v >= Luck.MIN and v <= Luck.MAX, "luck %d out of range" % v) + + +func test_modifier_shifts_result(): + # +100 modifier clamps to MAX regardless of rolls. + assert_eq(Luck.roll_base(_rng(5), 100), Luck.MAX) + + +func test_drift_stays_within_base_band_and_range(): + assert_eq(Luck.drift(10, 10, 100), 13) # capped at base+3 + assert_eq(Luck.drift(10, 10, -100), 7) # capped at base-3 + assert_eq(Luck.drift(20, 19, 100), 20) # base+3=22 -> clamped to MAX + + +func test_descriptor_bands(): + assert_eq(Luck.descriptor(4), "Fortune spits on you") + assert_eq(Luck.descriptor(5), "The world is not on your side") + assert_eq(Luck.descriptor(20), "The dice are kind today") diff --git a/client/tests/unit/test_luck.gd.uid b/client/tests/unit/test_luck.gd.uid new file mode 100644 index 0000000..7dfbea8 --- /dev/null +++ b/client/tests/unit/test_luck.gd.uid @@ -0,0 +1 @@ +uid://ca0o4rjy46crn