b351bc2d55
ci / verify (push) Successful in 48s
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.
The four ambiguities in the brief were settled with the user first, since
each changes what gets written:
damage base x (1 + sum additive) x product multiplicative. The flat
+5% every upgrade carries, spread's -10%, doubleshot's -50%
and glass cannon's +100% pool; sniper multiplies on top, so
two snipers is 4x and not +200%.
glass half the LEVELLED maximum, multiplying if taken twice, so the
price does not fade to a rounding error by level 15.
poison independent stacks, not a refresh.
split +/-45 degrees from the original heading.
Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.
Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.
Two invariants this collided with, both now pinned:
- bullet speed gained a ceiling. Wall collision samples once per tick, so
anything over a tile per tick tunnels; two snipers asked for 2480 u/s
against a 1920 threshold, and a tunnelling bullet looks like a bullet.
- BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
now the longest-travelling bullet in the game. test_interest measured
the worst case from static content, which upgrades quietly invalidated.
Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.
tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.
Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.
check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.5 KiB
GDScript
206 lines
7.5 KiB
GDScript
class_name Character
|
|
extends RefCounted
|
|
## One playable character belonging to an account.
|
|
##
|
|
## Characters outlive a session and a server restart, so this is a persistence
|
|
## record first and a gameplay object second: everything here has to survive a
|
|
## round trip through JSON without losing meaning.
|
|
##
|
|
## Death does not delete a character. It clears [member active], which retires
|
|
## it from the roster while keeping the record for archival and for working out
|
|
## what happened after the fact.
|
|
|
|
const MAX_NAME := 20
|
|
|
|
var id: String = ""
|
|
var display_name: String = "adventurer"
|
|
## Placeholder identity until there is a cosmetic system. Random per character
|
|
## so party members are told apart at a glance.
|
|
var colour := Color.WHITE
|
|
var level: int = Progression.START_LEVEL
|
|
var total_xp: int = 0
|
|
## False once the character has died. Never deleted -- see the class note.
|
|
var active: bool = true
|
|
var created_unix: int = 0
|
|
var died_unix: int = 0
|
|
## Carried items, one id per slot, [constant Items.NONE] where empty. Stored on
|
|
## the character rather than on the session so that swapping in the hub swaps
|
|
## bags, and so a server restart does not quietly confiscate everyone's potions.
|
|
var inventory: Array[StringName] = []
|
|
|
|
## Upgrades taken, in the order they were taken. Duplicates are meaningful --
|
|
## two Split Shots really is two splits -- so this is a list, not a set.
|
|
##
|
|
## The derived numbers (damage, fire rate, health multiplier) are NOT stored:
|
|
## [PlayerStats] rebuilds them from this list, so a saved stat can never
|
|
## disagree with the upgrades that produced it.
|
|
var upgrades: Array[StringName] = []
|
|
## Level-ups not yet spent at the hub NPC. They queue: reaching two levels in
|
|
## one run owes you two choices, because losing one for doing well is a
|
|
## punishment nobody would guess at.
|
|
var pending_choices: int = 0
|
|
## The choices currently on the table, held so that walking away and coming back
|
|
## shows the same three. Without this, closing and reopening the screen would be
|
|
## a free reroll until a legendary turned up.
|
|
var offer: Array[StringName] = []
|
|
|
|
|
|
## Suggested names, offered when creating a character so the field is never
|
|
## blank. Deliberately a pair of short word lists rather than a big table: the
|
|
## point is a usable default the player can overwrite, not a naming system.
|
|
const NAME_FIRST: PackedStringArray = [
|
|
"Ash", "Bram", "Cass", "Dorn", "Elm", "Fen", "Gale", "Hale", "Iva", "Jory",
|
|
"Kit", "Lark", "Mox", "Nell", "Orin", "Pike", "Quill", "Ren", "Sable", "Thorn",
|
|
"Vesper", "Wren", "Yarrow", "Zel",
|
|
]
|
|
const NAME_LAST: PackedStringArray = [
|
|
"blade", "briar", "creek", "dusk", "ember", "fell", "grim", "hollow",
|
|
"iron", "kettle", "moor", "night", "quarry", "rook", "shade", "thistle",
|
|
"vale", "wick",
|
|
]
|
|
|
|
|
|
static func random_name(rng: RandomNumberGenerator) -> String:
|
|
return "%s%s" % [
|
|
NAME_FIRST[rng.randi() % NAME_FIRST.size()],
|
|
NAME_LAST[rng.randi() % NAME_LAST.size()],
|
|
]
|
|
|
|
|
|
func _init() -> void:
|
|
inventory.resize(SimConfig.INVENTORY_SLOTS)
|
|
inventory.fill(Items.NONE)
|
|
|
|
|
|
static func create(character_name: String, rng: RandomNumberGenerator) -> Character:
|
|
var c := Character.new()
|
|
# Random-but-readable: full saturation and high value, so two characters are
|
|
# never both muddy browns, and none of them vanish against the floor.
|
|
c.colour = Color.from_hsv(rng.randf(), 0.55, 1.0)
|
|
c.id = "%d-%d" % [Time.get_unix_time_from_system(), rng.randi() & 0xFFFFFF]
|
|
c.display_name = sanitize_name(character_name)
|
|
c.created_unix = int(Time.get_unix_time_from_system())
|
|
return c
|
|
|
|
|
|
## Names come from clients and are shown to other players, so they are clamped
|
|
## here rather than trusted anywhere downstream.
|
|
static func sanitize_name(raw: String) -> String:
|
|
var clean := raw.strip_edges().substr(0, MAX_NAME)
|
|
# Control characters would let a name break the HUD's layout.
|
|
var out := ""
|
|
for ch in clean:
|
|
if ch.unicode_at(0) >= 32:
|
|
out += ch
|
|
out = out.strip_edges()
|
|
return out if not out.is_empty() else "adventurer"
|
|
|
|
|
|
## What this character actually walks around with: the level's health, scaled by
|
|
## whatever the upgrades do to it. Shown on the roster screen, so it has to be
|
|
## the real number and not the pre-upgrade one.
|
|
func max_hp() -> int:
|
|
return maxi(1, roundi(float(Progression.max_hp_for_level(level))
|
|
* PlayerStats.build(upgrades).max_hp_mult))
|
|
|
|
|
|
func xp_progress() -> float:
|
|
return Progression.level_progress(total_xp)
|
|
|
|
|
|
## Award experience and return how many levels it produced, so the caller can
|
|
## announce them. Level is derived from lifetime xp rather than tracked
|
|
## separately: one source of truth means a level can never disagree with the
|
|
## experience that earned it.
|
|
func grant_xp(amount: int) -> int:
|
|
if amount <= 0 or not active:
|
|
return 0
|
|
var before := level
|
|
total_xp += amount
|
|
level = Progression.level_for_xp(total_xp)
|
|
return level - before
|
|
|
|
|
|
func retire(when_unix: int = 0) -> void:
|
|
active = false
|
|
died_unix = when_unix if when_unix > 0 else int(Time.get_unix_time_from_system())
|
|
|
|
|
|
func to_dict() -> Dictionary:
|
|
return {
|
|
"id": id,
|
|
"name": display_name,
|
|
"colour": colour.to_html(false),
|
|
"level": level,
|
|
"xp": total_xp,
|
|
"active": active,
|
|
"created": created_unix,
|
|
"died": died_unix,
|
|
# Written as ids rather than indices: a save file has to survive
|
|
# Items.ORDER being appended to, and a human editing it should be able
|
|
# to tell what a character is carrying.
|
|
"inventory": _inventory_ids(),
|
|
"upgrades": _name_list(upgrades),
|
|
"pending_choices": pending_choices,
|
|
"offer": _name_list(offer),
|
|
}
|
|
|
|
|
|
static func _name_list(ids: Array[StringName]) -> Array:
|
|
var out := []
|
|
for id in ids:
|
|
out.append(String(id))
|
|
return out
|
|
|
|
|
|
func _inventory_ids() -> Array:
|
|
var out := []
|
|
for item in inventory:
|
|
out.append(String(item))
|
|
return out
|
|
|
|
|
|
## Tolerant of missing keys so an older save file still loads: a character that
|
|
## has lost a field is far better than an account that will not open.
|
|
static func from_dict(d: Dictionary) -> Character:
|
|
var c := Character.new()
|
|
c.id = String(d.get("id", ""))
|
|
c.display_name = sanitize_name(String(d.get("name", "")))
|
|
c.colour = Color.from_string(String(d.get("colour", "ffffff")), Color.WHITE)
|
|
c.total_xp = maxi(int(d.get("xp", 0)), 0)
|
|
# Derived, not read: a hand-edited or corrupted level cannot desync from xp.
|
|
c.level = Progression.level_for_xp(c.total_xp)
|
|
c.active = bool(d.get("active", true))
|
|
c.created_unix = int(d.get("created", 0))
|
|
c.died_unix = int(d.get("died", 0))
|
|
# Unknown ids decay to empty rather than to a wrong item -- a save written
|
|
# by a build with an item this one has never heard of must still load.
|
|
var carried: Array[StringName] = []
|
|
for raw in d.get("inventory", []):
|
|
var carried_id := StringName(String(raw))
|
|
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
|
|
c.set_inventory(carried)
|
|
# Unknown upgrade ids are dropped rather than kept as dead entries, so a
|
|
# save from a build with an upgrade this one lacks still produces coherent
|
|
# stats instead of a phantom that counts toward the +5% and does nothing.
|
|
c.upgrades = _known_upgrades(d.get("upgrades", []))
|
|
c.pending_choices = maxi(int(d.get("pending_choices", 0)), 0)
|
|
c.offer = _known_upgrades(d.get("offer", []))
|
|
return c
|
|
|
|
|
|
static func _known_upgrades(raw: Array) -> Array[StringName]:
|
|
var out: Array[StringName] = []
|
|
for entry in raw:
|
|
var id := StringName(String(entry))
|
|
if Upgrades.get_def(id) != null:
|
|
out.append(id)
|
|
return out
|
|
|
|
|
|
## Replace the whole inventory, padded or trimmed to the current slot count.
|
|
func set_inventory(items: Array[StringName]) -> void:
|
|
inventory.resize(SimConfig.INVENTORY_SLOTS)
|
|
for i in SimConfig.INVENTORY_SLOTS:
|
|
inventory[i] = items[i] if i < items.size() else Items.NONE
|