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:
31
client/scripts/state/game_state.gd
Normal file
31
client/scripts/state/game_state.gd
Normal 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
|
||||
1
client/scripts/state/game_state.gd.uid
Normal file
1
client/scripts/state/game_state.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c0ffensw2ym6w
|
||||
35
client/scripts/state/luck.gd
Normal file
35
client/scripts/state/luck.gd
Normal 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"]
|
||||
1
client/scripts/state/luck.gd.uid
Normal file
1
client/scripts/state/luck.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dah05dv3uoym
|
||||
30
client/tests/unit/test_game_state.gd
Normal file
30
client/tests/unit/test_game_state.gd
Normal 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")
|
||||
1
client/tests/unit/test_game_state.gd.uid
Normal file
1
client/tests/unit/test_game_state.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://chxglv8kbep2i
|
||||
36
client/tests/unit/test_luck.gd
Normal file
36
client/tests/unit/test_luck.gd
Normal 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")
|
||||
1
client/tests/unit/test_luck.gd.uid
Normal file
1
client/tests/unit/test_luck.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ca0o4rjy46crn
|
||||
Reference in New Issue
Block a user