feat(client): Luck (deterministic gen/drift/descriptor) + GameState store

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:24:59 -05:00
parent 4166370f9d
commit 0a9742a984
8 changed files with 136 additions and 0 deletions

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://c0ffensw2ym6w

View File

@@ -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"]

View File

@@ -0,0 +1 @@
uid://dah05dv3uoym

View File

@@ -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")

View File

@@ -0,0 +1 @@
uid://chxglv8kbep2i

View File

@@ -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")

View File

@@ -0,0 +1 @@
uid://ca0o4rjy46crn