diff --git a/CLAUDE.md b/CLAUDE.md index 1903aab..714b9ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,8 @@ Everything after `--` goes to `GameOpts.parse()`: | `--autoquit N` | Quit after N physics ticks. | | `--boss-rush` | Server-side: dungeons spawn the boss and no trash. | | `--depth N` | Server-side: depth of new dungeons, which drives map size. | +| `--account N` | Client-side: override the local account, so several clients can coexist on one machine. | +| `--store PATH` | Server-side: character store location. Use a scratch path in tests. | | `--verbose` / `--quiet` | Log level. | A change is done when `check.sh`, `test.sh` and — if it touched networking, @@ -70,6 +72,7 @@ and `tests/integration/test_replica_parity.gd` pin this down. | `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. | | `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. | | `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. | +| `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. | | `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. | | `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. | | `src/instances/` | Lobby hub and dungeon runs. | @@ -133,6 +136,12 @@ ticks in milliseconds with no SceneTree. - **Bullet speed must stay under one tile per tick.** Wall collision samples position once per tick, so anything faster tunnels. Pinned by `test_bullet_speeds_stay_below_the_tunnelling_threshold`. +- **Only `ServerRuntime` writes progression.** The simulation reads a player's + level and max health; it never grants experience or retires a character. One + writer means a level can never disagree with the experience that earned it. +- **`LocalAuthProvider` is insecure on purpose.** Any client can claim any + account. It exists to have the same shape as Steamworks (opaque ticket in, + 64-bit account id out) so swapping is one class. Do not ship it. - **No contact damage.** Every enemy threatens through bullets only; touching one is harmless. `tests/unit/test_content.gd` enforces that every hostile has an emitter. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c3029b7..f41a86a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -126,3 +126,38 @@ dungeon should give a bit more than is needed for the first level-up. **Permadeath.** Death marks a character inactive — never deleted, for archival and troubleshooting — and the player picks another character or creates one. + +--- + +## Characters and progression *(this session)* + +**Level 1 is base health; each level adds 10.** So level 15 is +`PLAYER_MAX_HP + 14 * 10` = 240. Level is *derived* from lifetime experience +rather than stored alongside it, so the two can never disagree — a hand-edited +save cannot produce a level 12 character with a level 3's experience. + +**The five-character cap counts LIVING characters only.** Retired ones stay in +the store forever but free their slot. Counting the dead would lock a player out +of their own account permanently after five deaths, which is not a punishment +anyone signed up for. + +**Death unbinds the character entirely.** There is deliberately no "return to +the hub as the character who just died" — the run is over, so the peer is +removed from the instance and left at the roster screen. The one exception is a +linkdead player, which has nobody to show a roster to, so its body is left for +the escape channel to resolve as before. + +**Experience is shared across the party, undivided.** Everyone alive in the +instance receives the full amount for a kill. Splitting it would make bringing a +friend cost you progress, which is the opposite of what the hub roster exists to +encourage. + +**A level-up heals by the amount it added.** Gaining a level mid-fight should +feel like relief, not like the bar you were watching got further from full. + +**The character store refuses to start rather than starting empty.** A corrupt +or unreadable save aborts the server. Loading empty would look like it worked +and then overwrite every character on the first level-up. + +**Account ids are written as decimal strings in JSON.** They are 64-bit and JSON +numbers are doubles, which would silently round them. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5da7962..5cd6620 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,7 +71,34 @@ play off server events. Enough to prove the pipeline, not a finished look. | **In-game credits screen** | **todo** | Not cosmetic: the SFX are CC BY 4.0 and attribution is a licence *requirement*. [CREDITS.md](../CREDITS.md) is not reachable by a player. | | Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). | -## Stage 2 — Characters, persistence, levels · *todo, next* +## Stage 2 — Characters, persistence, levels · *done* + +| Feature | State | Where | +| --- | --- | --- | +| Steam-shaped identity abstraction | done | [src/meta/auth_provider.gd](../src/meta/auth_provider.gd), [local_auth_provider.gd](../src/meta/local_auth_provider.gd) | +| Character store, JSON, survives restart | done | [src/meta/character_store.gd](../src/meta/character_store.gd) | +| Up to 5 living characters, random colour | done | `CharacterStore.MAX_ACTIVE`, `Character.create` | +| Last-played auto-selected on login | done | `CharacterStore.last_played` | +| Permadeath → retired, never deleted | done | `ServerRuntime._on_player_died` | +| Roster screen: pick or create | done | [src/ui/character_select.gd](../src/ui/character_select.gd) | +| Levels 1–15, +10 max HP each | done | [src/meta/progression.gd](../src/meta/progression.gd) | +| XP from kills, bosses worth far more | done | `ServerRuntime._award_kill` | +| Colour visible in world and on the HUD | done | snapshot carries it; `WorldView._draw_ship` tints | + +Verified end to end by `tools/diag_progression.tscn`, which drives the real +server through kill → xp → level → health and death → retire → roster. That +path cannot be covered by the bot smoke test, because bots are poor shots. + +### Still open in this area + +- **The local identity provider is insecure by design.** Any client can claim + any account id. Fine for a LAN; must be replaced before the game is reachable + from the internet. Swapping in Steam is one `AuthProvider` subclass and no + schema change. +- `--account` and `--store` exist so several clients and test runs can coexist + on one machine. A real provider makes `--account` unnecessary. + +## Stage 3 — Inventory and loot · *todo, next* Depends on nothing in Stage 1 except a place to stand. Blocked only on the identity layer, which is decided but unbuilt. @@ -92,7 +119,7 @@ Current behaviour to replace: `SimPlayer` has no identity beyond a peer id; --- -## Stage 3 — Upgrades · *todo* +## Stage 4 — Upgrades · *todo* | Feature | Notes | | --- | --- | @@ -110,8 +137,6 @@ bullets spawn per shot, so they belong in the same place. --- -## Stage 4 — Inventory and loot · *todo* - | Feature | Notes | | --- | --- | | Small always-on-screen inventory | Slot count unspecified. | diff --git a/src/autoload/net.gd b/src/autoload/net.gd index c0b59df..62ac9fa 100644 --- a/src/autoload/net.gd +++ b/src/autoload/net.gd @@ -22,6 +22,10 @@ var server: ServerRuntime = null var client: ClientRuntime = null var last_error: String = "" +## How this build establishes identity. One line to swap for a Steam provider: +## everything downstream deals only in the 64-bit account id it produces. +var auth: AuthProvider = LocalAuthProvider.new() + func _set_state(s: State) -> void: state = s @@ -116,7 +120,7 @@ func _on_peer_disconnected(peer_id: int) -> void: func _on_connected() -> void: _set_state(State.ONLINE) - c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name) + c_hello.rpc_id(1, Protocol.VERSION, auth.get_ticket()) func _on_connect_failed() -> void: @@ -142,7 +146,7 @@ func start_local_client() -> void: client = ClientRuntime.new() client.name = "Client" add_child(client) - server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name) + server.on_hello(LOCAL_PEER, Protocol.VERSION, auth.get_ticket()) func _is_local(peer_id: int) -> bool: @@ -194,6 +198,20 @@ func send_events(peer_id: int, data: PackedByteArray) -> void: s_events.rpc_id(peer_id, data) +func send_characters(peer_id: int, data: PackedByteArray) -> void: + if _is_local(peer_id): + client.on_characters(data) + else: + s_characters.rpc_id(peer_id, data) + + +func send_select_result(peer_id: int, result: int, reason: String) -> void: + if _is_local(peer_id): + client.on_select_result(result, reason) + else: + s_select_result.rpc_id(peer_id, result, reason) + + func send_roster(peer_id: int, data: PackedByteArray) -> void: if _is_local(peer_id): client.on_roster(data) @@ -208,6 +226,20 @@ func send_reject(peer_id: int, reason: String) -> void: s_reject.rpc_id(peer_id, reason) +func select_character(character_id: String) -> void: + if server != null: + server.on_select_character(LOCAL_PEER, character_id) + elif state == State.ONLINE: + c_select_character.rpc_id(1, character_id) + + +func create_character(character_name: String) -> void: + if server != null: + server.on_create_character(LOCAL_PEER, character_name) + elif state == State.ONLINE: + c_create_character.rpc_id(1, character_name) + + func send_input(data: PackedByteArray) -> void: if server != null: server.on_input(LOCAL_PEER, data) # listen server: no transport at all @@ -218,10 +250,24 @@ func send_input(data: PackedByteArray) -> void: # --- Client -> server ------------------------------------------------------- @rpc("any_peer", "call_remote", "reliable", 1) -func c_hello(version: int, display_name: String) -> void: +func c_hello(version: int, ticket: PackedByteArray) -> void: if server == null: return - server.on_hello(multiplayer.get_remote_sender_id(), version, display_name) + server.on_hello(multiplayer.get_remote_sender_id(), version, ticket) + + +@rpc("any_peer", "call_remote", "reliable", 1) +func c_select_character(character_id: String) -> void: + if server == null: + return + server.on_select_character(multiplayer.get_remote_sender_id(), character_id) + + +@rpc("any_peer", "call_remote", "reliable", 1) +func c_create_character(character_name: String) -> void: + if server == null: + return + server.on_create_character(multiplayer.get_remote_sender_id(), character_name) @rpc("any_peer", "call_remote", "unreliable_ordered", 4) @@ -262,6 +308,18 @@ func s_reject(reason: String) -> void: _set_state(State.FAILED) +@rpc("authority", "call_remote", "reliable", 1) +func s_characters(data: PackedByteArray) -> void: + if client != null: + client.on_characters(data) + + +@rpc("authority", "call_remote", "reliable", 1) +func s_select_result(result: int, reason: String) -> void: + if client != null: + client.on_select_result(result, reason) + + @rpc("authority", "call_remote", "reliable", 1) func s_roster(data: PackedByteArray) -> void: if client != null: diff --git a/src/core/game_opts.gd b/src/core/game_opts.gd index a0035de..5a43bcb 100644 --- a/src/core/game_opts.gd +++ b/src/core/game_opts.gd @@ -30,6 +30,15 @@ static var boss_rush: bool = false ## Dev switch: depth of newly opened dungeons, which drives map size. Depth ## progression is a later-stage concern; this makes big maps testable now. static var dungeon_depth: int = 1 +## Override the local account id. Two clients on one machine would otherwise +## read the same user:// id file, land on the same account, and fight over one +## roster of characters -- which is exactly what the smoke test does. A real +## identity provider makes this unnecessary. +static var account_override: int = 0 +## Where the server keeps characters. Overridable so a test run cannot read or +## write the characters someone is actually playing -- and so repeated runs +## start from a known state instead of resuming each other's progress. +static var store_path: String = CharacterStore.SAVE_PATH static var parsed: bool = false @@ -54,6 +63,14 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void: listen = true "--boss-rush": boss_rush = true + "--account": + i += 1 + if i < argv.size(): + account_override = int(argv[i]) + "--store": + i += 1 + if i < argv.size(): + store_path = argv[i] "--depth": i += 1 if i < argv.size(): diff --git a/src/game.tscn b/src/game.tscn index 5e974dc..322c597 100644 --- a/src/game.tscn +++ b/src/game.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=7 format=3] +[gd_scene load_steps=8 format=3] [ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"] [ext_resource type="Script" path="res://src/view/world_view.gd" id="2"] @@ -6,6 +6,7 @@ [ext_resource type="Script" path="res://src/ui/hud.gd" id="4"] [ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"] [ext_resource type="Script" path="res://src/view/sfx.gd" id="6"] +[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"] [node name="Game" type="Node2D"] script = ExtResource("1") @@ -25,3 +26,6 @@ script = ExtResource("5") [node name="Sfx" type="Node" parent="."] script = ExtResource("6") + +[node name="CharacterSelect" type="CanvasLayer" parent="."] +script = ExtResource("7") diff --git a/src/meta/auth_provider.gd b/src/meta/auth_provider.gd new file mode 100644 index 0000000..d488a5a --- /dev/null +++ b/src/meta/auth_provider.gd @@ -0,0 +1,28 @@ +@abstract +class_name AuthProvider +extends RefCounted +## How the server learns who a client is. +## +## Modelled on Steamworks deliberately, so swapping to it later is one subclass +## and no schema change: the client obtains an opaque ticket, sends it with the +## handshake, and the server validates it into a stable 64-bit account id -- +## exactly the shape of GetAuthSessionTicket / BeginAuthSession / SteamID64. +## +## The account id is the only identity anything downstream sees. Nothing stores +## a ticket, and nothing outside a provider interprets one. + +## Reserved: never a valid account. +const NO_ACCOUNT := 0 + + +## Client side: a ticket to present at handshake. +@abstract func get_ticket() -> PackedByteArray + + +## Server side: validate a ticket and return the account it proves, or +## NO_ACCOUNT to reject the connection. +@abstract func validate(ticket: PackedByteArray) -> int + + +## Human-readable, for logs and for the connect screen. +@abstract func provider_name() -> String diff --git a/src/meta/auth_provider.gd.uid b/src/meta/auth_provider.gd.uid new file mode 100644 index 0000000..4296cd0 --- /dev/null +++ b/src/meta/auth_provider.gd.uid @@ -0,0 +1 @@ +uid://fteslbujpkuo diff --git a/src/meta/character.gd b/src/meta/character.gd new file mode 100644 index 0000000..299d3ec --- /dev/null +++ b/src/meta/character.gd @@ -0,0 +1,104 @@ +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 + + +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" + + +func max_hp() -> int: + return Progression.max_hp_for_level(level) + + +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, + } + + +## 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)) + return c diff --git a/src/meta/character.gd.uid b/src/meta/character.gd.uid new file mode 100644 index 0000000..a6a31f3 --- /dev/null +++ b/src/meta/character.gd.uid @@ -0,0 +1 @@ +uid://tuerwia65i3i diff --git a/src/meta/character_store.gd b/src/meta/character_store.gd new file mode 100644 index 0000000..c26a701 --- /dev/null +++ b/src/meta/character_store.gd @@ -0,0 +1,175 @@ +class_name CharacterStore +extends RefCounted +## Server-side persistence for accounts and their characters. +## +## JSON on disk, because the shape is small, the write rate is low (a level-up +## or a death, not a tick), and a save file you can open in a text editor is +## worth a great deal while a game is still being built. If this ever becomes a +## bottleneck the interface is narrow enough to put a database behind. +## +## Keyed by account id -- a 64-bit integer, deliberately the same shape as a +## SteamID64 so swapping [AuthProvider] for a real Steam one needs no migration. + +const SAVE_PATH := "user://characters.json" +const FORMAT_VERSION := 1 +## Live characters an account may hold at once. Retired ones do not count: a +## player who has died five times must not be locked out of their own account. +const MAX_ACTIVE := 5 + +## account_id -> { "characters": Array[Character], "last_played": String } +var _accounts: Dictionary[int, Dictionary] = {} +var _path: String = SAVE_PATH +var _rng := RandomNumberGenerator.new() + + +func _init(path: String = SAVE_PATH) -> void: + _path = path + _rng.randomize() + + +# --- Queries ---------------------------------------------------------------- + +func characters_for(account_id: int) -> Array[Character]: + var entry: Dictionary = _accounts.get(account_id, {}) + var out: Array[Character] = [] + for c in entry.get("characters", []): + out.append(c) + return out + + +func active_characters(account_id: int) -> Array[Character]: + var out: Array[Character] = [] + for c in characters_for(account_id): + if c.active: + out.append(c) + return out + + +func get_character(account_id: int, character_id: String) -> Character: + for c in characters_for(account_id): + if c.id == character_id: + return c + return null + + +## The character to select on login: the last one played if it is still alive, +## otherwise the newest living one, otherwise nothing. +func last_played(account_id: int) -> Character: + var entry: Dictionary = _accounts.get(account_id, {}) + var wanted := String(entry.get("last_played", "")) + var c := get_character(account_id, wanted) + if c != null and c.active: + return c + var living := active_characters(account_id) + if living.is_empty(): + return null + var newest: Character = living[0] + for candidate in living: + if candidate.created_unix > newest.created_unix: + newest = candidate + return newest + + +func can_create(account_id: int) -> bool: + return active_characters(account_id).size() < MAX_ACTIVE + + +# --- Mutations -------------------------------------------------------------- + +## Returns null when the account is already at its living-character limit. +func create_character(account_id: int, character_name: String) -> Character: + if not can_create(account_id): + return null + var c := Character.create(character_name, _rng) + var entry: Dictionary = _accounts.get(account_id, {"characters": [], "last_played": ""}) + entry["characters"].append(c) + entry["last_played"] = c.id + _accounts[account_id] = entry + save() + return c + + +func set_last_played(account_id: int, character_id: String) -> void: + var entry: Dictionary = _accounts.get(account_id, {"characters": [], "last_played": ""}) + entry["last_played"] = character_id + _accounts[account_id] = entry + save() + + +## Death. The record stays; only its active flag changes. +func retire_character(account_id: int, character_id: String) -> void: + var c := get_character(account_id, character_id) + if c == null or not c.active: + return + c.retire() + save() + + +func grant_xp(account_id: int, character_id: String, amount: int) -> int: + var c := get_character(account_id, character_id) + if c == null: + return 0 + var gained := c.grant_xp(amount) + if gained > 0: + save() + return gained + + +# --- Persistence ------------------------------------------------------------ + +func save() -> void: + var accounts := {} + for account_id in _accounts: + var entry: Dictionary = _accounts[account_id] + var chars := [] + for c in entry["characters"]: + chars.append(c.to_dict()) + # JSON object keys are strings; account ids are 64-bit and would lose + # precision as JSON numbers, so they are written as decimal strings. + accounts[str(account_id)] = { + "characters": chars, + "last_played": entry.get("last_played", ""), + } + var f := FileAccess.open(_path, FileAccess.WRITE) + if f == null: + GameLog.error("store", "cannot write %s (error %d)" % [_path, FileAccess.get_open_error()]) + return + f.store_string(JSON.stringify({"version": FORMAT_VERSION, "accounts": accounts}, "\t")) + f.close() + + +## A missing file is a new server, not an error. A corrupt one is refused +## loudly and left alone rather than silently overwritten -- losing every +## character to a stray byte would be far worse than refusing to start. +func load_from_disk() -> bool: + _accounts.clear() + if not FileAccess.file_exists(_path): + return true + var f := FileAccess.open(_path, FileAccess.READ) + if f == null: + GameLog.error("store", "cannot read %s" % _path) + return false + var text := f.get_as_text() + f.close() + # JSON.new().parse() rather than JSON.parse_string(): it reports where the + # file is malformed instead of just returning null, which is the difference + # between a usable error and a mystery when someone's save will not load. + var json := JSON.new() + if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY: + GameLog.error("store", "%s is not valid JSON (line %d: %s); refusing to overwrite it" + % [_path, json.get_error_line(), json.get_error_message()]) + return false + var data: Dictionary = json.data + var accounts: Dictionary = data.get("accounts", {}) + for key in accounts: + var account_id := int(str(key)) + var entry: Dictionary = accounts[key] + var chars: Array[Character] = [] + for raw in entry.get("characters", []): + chars.append(Character.from_dict(raw)) + _accounts[account_id] = { + "characters": chars, + "last_played": String(entry.get("last_played", "")), + } + GameLog.info("store", "loaded %d account(s) from %s" % [_accounts.size(), _path]) + return true diff --git a/src/meta/character_store.gd.uid b/src/meta/character_store.gd.uid new file mode 100644 index 0000000..6fed88a --- /dev/null +++ b/src/meta/character_store.gd.uid @@ -0,0 +1 @@ +uid://becirpnytb1b8 diff --git a/src/meta/local_auth_provider.gd b/src/meta/local_auth_provider.gd new file mode 100644 index 0000000..ca58384 --- /dev/null +++ b/src/meta/local_auth_provider.gd @@ -0,0 +1,63 @@ +class_name LocalAuthProvider +extends AuthProvider +## Development identity: no accounts, no passwords, no Steam. +## +## The client generates a 64-bit id once, stores it in user://, and presents it +## as its own ticket. The server takes it at face value. +## +## This is NOT secure and is not meant to be. Anyone can present any id, so +## anyone can claim any account's characters. It is deliberately the same shape +## as the real thing -- opaque ticket in, 64-bit account id out -- so the Steam +## provider replaces it without touching the character store, the protocol, or +## anything that consumes an account id. +## +## Before this game is reachable from the internet, this must be swapped for a +## provider that actually verifies. See docs/ROADMAP.md. + +const ID_PATH := "user://account_id" + +var _cached: int = AuthProvider.NO_ACCOUNT + + +func provider_name() -> String: + return "local-dev (insecure)" + + +## Read this machine's id, generating and saving one on first run. +func account_id() -> int: + if _cached != AuthProvider.NO_ACCOUNT: + return _cached + if GameOpts.account_override != AuthProvider.NO_ACCOUNT: + _cached = GameOpts.account_override + return _cached + if FileAccess.file_exists(ID_PATH): + var f := FileAccess.open(ID_PATH, FileAccess.READ) + if f != null: + var parsed := int(f.get_as_text().strip_edges()) + f.close() + if parsed != AuthProvider.NO_ACCOUNT: + _cached = parsed + return _cached + var rng := RandomNumberGenerator.new() + rng.randomize() + # Positive and comfortably inside 64 bits, so it round-trips through the + # store's decimal-string keys without surprises. + _cached = absi(rng.randi()) << 20 | (absi(rng.randi()) & 0xFFFFF) + var out := FileAccess.open(ID_PATH, FileAccess.WRITE) + if out != null: + out.store_string(str(_cached)) + out.close() + GameLog.info("auth", "generated local account id %d" % _cached) + return _cached + + +func get_ticket() -> PackedByteArray: + return str(account_id()).to_utf8_buffer() + + +## Accepts whatever it is given, which is the entire security model here. +func validate(ticket: PackedByteArray) -> int: + if ticket.is_empty() or ticket.size() > 64: + return AuthProvider.NO_ACCOUNT + var id := int(ticket.get_string_from_utf8().strip_edges()) + return id if id > 0 else AuthProvider.NO_ACCOUNT diff --git a/src/meta/local_auth_provider.gd.uid b/src/meta/local_auth_provider.gd.uid new file mode 100644 index 0000000..4c9629b --- /dev/null +++ b/src/meta/local_auth_provider.gd.uid @@ -0,0 +1 @@ +uid://hxyq22vllwgt diff --git a/src/meta/progression.gd b/src/meta/progression.gd new file mode 100644 index 0000000..64b57bd --- /dev/null +++ b/src/meta/progression.gd @@ -0,0 +1,89 @@ +class_name Progression +extends RefCounted +## Levels, experience and what a level is worth. +## +## Pure functions over integers, so the curve can be tuned and tested without a +## server, a character, or a running game. The server is the only thing that +## ever calls the mutating side of this; a client is told its level and takes +## the server's word for it. + +const MAX_LEVEL := 15 +const START_LEVEL := 1 +## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a +## capped character has PLAYER_MAX_HP + 14 * this. +const HP_PER_LEVEL := 10 + +## Experience for the first level-up. The curve is tuned so one full clear of a +## depth-1 dungeon lands a little past this -- the first run should end with a +## level and some change, not exactly on the line. +const BASE_XP := 200 +## How sharply the requirement grows. 1.0 would be linear; this makes level 15 a +## long-term goal without making level 2 feel far away. +const XP_CURVE := 1.35 + +## Experience awarded per kill, by what died. +const XP_DRIFTER := 10 +const XP_TURRET := 12 +const XP_STALKER := 8 +const XP_BOSS := 200 + + +## Experience needed to go from [param level] to the next one. Zero at the cap, +## which is what makes "already maxed" a total-ordering question rather than a +## special case at every call site. +static func xp_to_next(level: int) -> int: + if level >= MAX_LEVEL: + return 0 + return int(round(float(BASE_XP) * pow(float(level), XP_CURVE))) + + +## Total experience to reach [param level] from level 1. +static func total_xp_for_level(level: int) -> int: + var total := 0 + for l in range(START_LEVEL, mini(level, MAX_LEVEL)): + total += xp_to_next(l) + return total + + +## The level a given lifetime experience total corresponds to. +static func level_for_xp(total_xp: int) -> int: + var level := START_LEVEL + var spent := 0 + while level < MAX_LEVEL: + var need := xp_to_next(level) + if total_xp - spent < need: + break + spent += need + level += 1 + return level + + +## Progress through the current level, 0..1. Returns 1.0 at the cap so a +## progress bar reads as full rather than empty. +static func level_progress(total_xp: int) -> float: + var level := level_for_xp(total_xp) + if level >= MAX_LEVEL: + return 1.0 + var into := total_xp - total_xp_for_level(level) + var need := xp_to_next(level) + return clampf(float(into) / float(maxi(need, 1)), 0.0, 1.0) + + +static func max_hp_for_level(level: int) -> int: + var l := clampi(level, START_LEVEL, MAX_LEVEL) + return SimConfig.PLAYER_MAX_HP + (l - START_LEVEL) * HP_PER_LEVEL + + +## Experience for killing an enemy, by content id. Unknown ids award nothing +## rather than a default, so a new enemy that nobody scored is obvious in play +## instead of quietly paying out. +static func xp_for_enemy(id: StringName) -> int: + match id: + Content.ENEMY_DRIFTER: return XP_DRIFTER + Content.ENEMY_TURRET: return XP_TURRET + Content.ENEMY_STALKER: return XP_STALKER + return 0 + + +static func xp_for_boss(_id: StringName) -> int: + return XP_BOSS diff --git a/src/meta/progression.gd.uid b/src/meta/progression.gd.uid new file mode 100644 index 0000000..ab99d7d --- /dev/null +++ b/src/meta/progression.gd.uid @@ -0,0 +1 @@ +uid://cjtfbpej8ywwf diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 37adf63..17570e0 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -15,6 +15,9 @@ signal local_hit(damage: int) signal shot_fired signal enemy_died signal boss_died +## The account's character roster changed: created, selected, levelled or died. +signal characters_changed +signal select_failed(reason: String) var my_peer: int = 0 var instance_id: int = 0 @@ -38,6 +41,9 @@ var _last_move := Vector2.ZERO # Authoritative mirror of the local player. var my_hp: int = SimConfig.PLAYER_MAX_HP +## Follows the character's level, so the HUD bar cannot be computed from a +## constant. +var my_max_hp: int = SimConfig.PLAYER_MAX_HP var my_alive: bool = true var my_escape: float = 0.0 var my_escaping: bool = false @@ -59,6 +65,14 @@ var request_escape: bool = false ## Who is online and where, for the hub's player list. Server-pushed. var roster: Array[Dictionary] = [] +## This account's characters, and which one is being played. Server-pushed; +## the client never invents an entry. +var characters: Array[Dictionary] = [] +var selected_character: String = "" +## True once the server has told us the roster, so the UI can tell "no +## characters yet" from "not asked yet". +var characters_known: bool = false + ## Whole seconds until a cleared dungeon returns the party, or ## Protocol.COUNTDOWN_NONE outside that state. var cleared_countdown: int = Protocol.COUNTDOWN_NONE @@ -227,6 +241,49 @@ func on_map_chunks(from_instance: int, data: PackedByteArray) -> void: NetCodec.decode_map_chunks_into(world.map, data) +func on_characters(data: PackedByteArray) -> void: + var decoded := NetCodec.decode_characters(data) + characters = decoded["characters"] + selected_character = String(decoded["selected"]) + characters_known = true + characters_changed.emit() + hud_dirty.emit() + _bot_pick_character() + + +## A bot has no roster screen to click, so it makes the choice the screen would +## offer: resume a living character, or create one. Without this the smoke test +## would authenticate and then stand at a menu forever. +func _bot_pick_character() -> void: + if not GameOpts.bot_client or not selected_character.is_empty(): + return + for c in characters: + if c["active"]: + Net.select_character(String(c["id"])) + return + Net.create_character(GameOpts.player_name) + + +func on_select_result(result: int, reason: String) -> void: + if result != Protocol.SelectResult.OK: + GameLog.warn("client", "character selection refused: %s" % reason) + select_failed.emit(reason) + + +## The character currently being played, or an empty dictionary while none is. +func current_character() -> Dictionary: + for c in characters: + if String(c["id"]) == selected_character: + return c + return {} + + +## True when the player has no character in the world and must pick one -- at +## first login, or after their last one died. +func needs_character() -> bool: + return characters_known and selected_character.is_empty() + + func on_roster(data: PackedByteArray) -> void: roster = NetCodec.decode_roster(data) hud_dirty.emit() @@ -301,6 +358,7 @@ func on_snapshot(data: PackedByteArray) -> void: ## land where the client should actually be right now. func _reconcile(rec: Dictionary) -> void: my_hp = int(rec["hp"]) + my_max_hp = int(rec["max_hp"]) my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0 my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0 my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0 diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index 3b0377e..5f29b1b 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -45,6 +45,10 @@ static func encode_snapshot(world: SimWorld, b.put_float(p.pos.y) b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536)) b.put_u16(clampi(p.hp, 0, 65535)) + # Sent rather than assumed: max health follows the character's level, so + # a HUD bar computed from a constant would be wrong for anyone past + # level 1, and wrong for every other player in the party. + b.put_u16(clampi(p.max_hp, 1, 65535)) var flags := 0 if p.alive: flags |= Protocol.F_ALIVE @@ -61,6 +65,10 @@ static func encode_snapshot(world: SimWorld, b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255)) # Echoed so the owning client knows how far to rewind when reconciling. b.put_u32(p.last_input_tick) + # Static per character, so sending it every snapshot is slightly + # wasteful -- but it is four bytes, and the alternative is a separate + # message plus the join-ordering bug where someone arrives before it. + b.put_u32(p.colour.to_rgba32()) var live_enemies: Array[SimEnemy] = [] for e in world.enemies.values(): @@ -111,10 +119,12 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary: "pos": Vector2(b.get_float(), b.get_float()), "aim": float(b.get_u16()) / 65536.0 * TAU, "hp": b.get_u16(), + "max_hp": b.get_u16(), "flags": b.get_u8(), "escape": float(b.get_u8()) / 255.0, "respawn_wait": float(b.get_u8()) / 10.0, "last_input_tick": b.get_u32(), + "colour": Color.hex(b.get_u32()), }) var ecount := b.get_u16() @@ -353,3 +363,50 @@ static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int: map.apply_chunk(id, b.get_data(n)[1]) applied += 1 return applied + + +# --- Character roster ------------------------------------------------------- +# Sent once at login and after any change. Low frequency and carries strings, +# like the online roster, so it is the same fixed-header-then-utf8 shape. + +static func encode_characters(chars: Array[Character], selected: String) -> PackedByteArray: + var b := StreamPeerBuffer.new() + b.big_endian = false + b.put_utf8_string(selected) + b.put_u8(mini(chars.size(), 255)) + for c in chars: + b.put_utf8_string(c.id) + b.put_utf8_string(c.display_name) + b.put_u8(clampi(c.level, 1, 255)) + b.put_u32(maxi(c.total_xp, 0)) + b.put_u8(clampi(roundi(c.xp_progress() * 255.0), 0, 255)) + b.put_u16(clampi(c.max_hp(), 1, 65535)) + b.put_u8(1 if c.active else 0) + b.put_u32(c.colour.to_rgba32()) + return b.data_array + + +## Returns { "selected": String, "characters": Array[Dictionary] }. +static func decode_characters(data: PackedByteArray) -> Dictionary: + var out: Array[Dictionary] = [] + if data.size() < 1: + return {"selected": "", "characters": out} + var b := StreamPeerBuffer.new() + b.big_endian = false + b.data_array = data + var selected := b.get_utf8_string() + var count := b.get_u8() + for _i in count: + if b.get_available_bytes() <= 0: + break + out.append({ + "id": b.get_utf8_string(), + "name": b.get_utf8_string(), + "level": b.get_u8(), + "xp": b.get_u32(), + "progress": float(b.get_u8()) / 255.0, + "max_hp": b.get_u16(), + "active": b.get_u8() == 1, + "colour": Color.hex(b.get_u32()), + }) + return {"selected": selected, "characters": out} diff --git a/src/net/protocol.gd b/src/net/protocol.gd index 4c8607e..31ad048 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -10,7 +10,9 @@ extends RefCounted ## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts ## the wire value of every event after it -- a mismatched client would ## mis-decode every hit and death, so the handshake has to reject it. -const VERSION := 4 +## 5: handshake carries an auth ticket instead of a bare name; added character +## list/select/create messages and per-player max health in the snapshot. +const VERSION := 5 const DEFAULT_PORT := 27015 const MAX_CLIENTS := 32 @@ -25,6 +27,10 @@ const CHANNEL_COUNT := 8 enum InstanceKind { LOBBY, DUNGEON } +## Why a character selection failed. Sent rather than a bare "no", so the UI can +## say something useful instead of appearing broken. +enum SelectResult { OK, NO_SUCH_CHARACTER, CHARACTER_IS_DEAD, LIMIT_REACHED, NOT_AUTHENTICATED } + ## Player flags packed into the snapshot's per-player byte. const F_ALIVE := 1 const F_ESCAPING := 4 diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index 1e957b1..aba7777 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -11,6 +11,14 @@ extends Node var instances: Dictionary[int, Instance] = {} var peer_instance: Dictionary[int, int] = {} var peer_names: Dictionary[int, String] = {} +## Authenticated account behind each peer. Set at handshake and never taken +## from anything the client says afterwards. +var peer_accounts: Dictionary[int, int] = {} +## Which character each peer is currently playing. +var peer_characters: Dictionary[int, String] = {} +## Characters, levels and experience. Owned here: the simulation reads a +## player's level, but only this layer ever writes progression. +var store: CharacterStore = null ## Map chunks each peer has been sent, per peer. Reset on every instance ## transfer -- knowledge of one dungeon must not carry into the next. var peer_chunks: Dictionary[int, Dictionary] = {} @@ -21,6 +29,14 @@ var _snapshot_phase: int = 0 func _ready() -> void: + if store == null: + store = CharacterStore.new(GameOpts.store_path) + if not store.load_from_disk(): + # Refusing to start beats starting empty and saving over everyone's + # characters on the first level-up. + GameLog.error("server", "character store failed to load; refusing to start") + get_tree().quit(1) + return lobby = Instance.make_lobby(_take_instance_id()) instances[lobby.id] = lobby GameLog.info("server", "lobby instance %d up" % lobby.id) @@ -74,8 +90,18 @@ func _dispatch_events(inst: Instance) -> void: # inst.peers and would otherwise change the list mid-broadcast. var to_lobby: Array[int] = [] var to_dungeon: Array[int] = [] + var died: Array[int] = [] for ev in events: match int(ev["t"]): + SimEvent.Type.ENEMY_DIED: + _award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", "")))) + SimEvent.Type.BOSS_DIED: + _award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", "")))) + SimEvent.Type.PLAYER_DIED: + # Deferred like the transfers below: the payload has not been + # sent yet, and a player must still receive news of its own + # death before it stops being a member of the instance. + died.append(int(ev["peer"])) SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED: var peer := int(ev["peer"]) if not to_lobby.has(peer): @@ -107,6 +133,8 @@ func _dispatch_events(inst: Instance) -> void: continue Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer)) + for peer in died: + _on_player_died(inst, peer) for peer in to_lobby: _send_to_lobby(peer) for peer in to_dungeon: @@ -145,6 +173,8 @@ func _forget_peer(peer_id: int) -> void: peer_instance.erase(peer_id) peer_names.erase(peer_id) peer_chunks.erase(peer_id) + peer_accounts.erase(peer_id) + peer_characters.erase(peer_id) _broadcast_roster() @@ -158,22 +188,98 @@ func _release_linkdead(peer_id: int, inst: Instance) -> void: _forget_peer(peer_id) -func on_hello(peer_id: int, version: int, display_name: String) -> void: - if peer_names.has(peer_id): +## Handshake: validate the ticket into an account, then offer that account's +## characters. A peer is NOT placed in the world here -- it has no character +## yet, and a player without a character has nothing to control. +func on_hello(peer_id: int, version: int, ticket: PackedByteArray) -> void: + if peer_accounts.has(peer_id): return # a second hello from the same peer is either a bug or an attack if version != Protocol.VERSION: GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION]) Net.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version]) Net.kick(peer_id) return - # Never trust a client-supplied string for anything but display. - var clean := display_name.strip_edges().substr(0, 24) - if clean.is_empty(): - clean = "player%d" % peer_id - peer_names[peer_id] = clean + var account := Net.auth.validate(ticket) + if account == AuthProvider.NO_ACCOUNT: + GameLog.warn("server", "peer %d failed authentication" % peer_id) + Net.send_reject(peer_id, "authentication failed") + Net.kick(peer_id) + return + peer_accounts[peer_id] = account Net.send_welcome(peer_id) + GameLog.info("server", "peer %d authenticated as account %d (%s)" + % [peer_id, account, Net.auth.provider_name()]) + + # Auto-select the last character played, so a returning player lands in the + # hub rather than at a menu they have already answered. + var resume := store.last_played(account) + if resume != null: + _enter_world_as(peer_id, resume) + _send_characters(peer_id) + + +func _send_characters(peer_id: int) -> void: + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + if account == AuthProvider.NO_ACCOUNT: + return + Net.send_characters(peer_id, NetCodec.encode_characters( + store.characters_for(account), peer_characters.get(peer_id, ""))) + + +func on_select_character(peer_id: int, character_id: String) -> void: + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + if account == AuthProvider.NO_ACCOUNT: + Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in") + return + # Looked up against THIS account's characters, so a client cannot select + # somebody else's by guessing an id. + var c := store.get_character(account, character_id) + if c == null: + Net.send_select_result(peer_id, Protocol.SelectResult.NO_SUCH_CHARACTER, "no such character") + return + if not c.active: + Net.send_select_result(peer_id, Protocol.SelectResult.CHARACTER_IS_DEAD, + "%s is dead" % c.display_name) + return + _enter_world_as(peer_id, c) + Net.send_select_result(peer_id, Protocol.SelectResult.OK, "") + _send_characters(peer_id) + + +func on_create_character(peer_id: int, character_name: String) -> void: + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + if account == AuthProvider.NO_ACCOUNT: + Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in") + return + var c := store.create_character(account, character_name) + if c == null: + Net.send_select_result(peer_id, Protocol.SelectResult.LIMIT_REACHED, + "%d living characters is the limit" % CharacterStore.MAX_ACTIVE) + return + GameLog.info("server", "account %d created '%s'" % [account, c.display_name]) + _enter_world_as(peer_id, c) + Net.send_select_result(peer_id, Protocol.SelectResult.OK, "") + _send_characters(peer_id) + + +## Put a peer into the hub playing [param c], switching characters if it was +## already in the world. +func _enter_world_as(peer_id: int, c: Character) -> void: + var account: int = peer_accounts[peer_id] + var previous := instance_of(peer_id) + if previous != null: + previous.remove_peer(peer_id) + peer_characters[peer_id] = c.id + peer_names[peer_id] = c.display_name + store.set_last_played(account, c.id) _place(peer_id, lobby) - GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean]) + var p: SimPlayer = lobby.world.players.get(peer_id) + if p != null: + p.adopt(c) + p.reset_for_instance(lobby.world.spawn_point, 0) + p.adopt(c) + GameLog.info("server", "peer %d playing '%s' (level %d)" + % [peer_id, c.display_name, c.level]) _broadcast_roster() @@ -228,6 +334,23 @@ func _send_to_lobby(peer_id: int) -> void: if p != null and p.linkdead: _release_linkdead(peer_id, from) return + # The character that just died is retired, so returning "as them" is not an + # option. Fall back to whatever is left, and leave the player at the + # character screen if nothing is. + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + if account != AuthProvider.NO_ACCOUNT: + var current := store.get_character(account, peer_characters.get(peer_id, "")) + if current == null or not current.active: + var replacement := store.last_played(account) + if replacement != null: + _enter_world_as(peer_id, replacement) + else: + if from != null: + from.remove_peer(peer_id) + peer_instance.erase(peer_id) + peer_characters.erase(peer_id) + _send_characters(peer_id) + return GameLog.info("server", "peer %d escaped to lobby" % peer_id) _transfer(peer_id, lobby) @@ -283,6 +406,78 @@ func _stream_map(peer_id: int, inst: Instance) -> void: NetCodec.encode_map_chunks(inst.world.map, batch)) +## Experience is shared by everyone alive in the instance, undivided. Splitting +## it would make bringing a friend cost you progress, which is the opposite of +## what a co-op game wants; the hub roster exists to help people group up. +func _award_kill(inst: Instance, amount: int) -> void: + if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON: + return + for peer in inst.peers: + var p: SimPlayer = inst.world.players.get(peer) + if p == null or not p.alive: + continue + _grant_xp(peer, amount) + + +func _grant_xp(peer_id: int, amount: int) -> void: + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + var character_id: String = peer_characters.get(peer_id, "") + if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): + return + var levels := store.grant_xp(account, character_id, amount) + if levels <= 0: + return + # A level raises max health immediately, and heals by the amount gained -- + # a level-up mid-fight should feel like relief, not like a bar that grew + # further away from full. + var c := store.get_character(account, character_id) + var inst := instance_of(peer_id) + if inst != null: + var p: SimPlayer = inst.world.players.get(peer_id) + if p != null: + var before := p.max_hp + p.level = c.level + p.max_hp = c.max_hp() + p.hp = mini(p.hp + (p.max_hp - before), p.max_hp) + GameLog.info("server", "peer %d reached level %d" % [peer_id, c.level]) + _send_characters(peer_id) + + +## Death is permanent. The character is retired -- kept for archival, never +## deleted -- and the player is taken out of the world entirely. +## +## There is deliberately no "return to the hub as the character who just died": +## the run is over, so the peer is unbound and left at the roster screen to pick +## another or make one. A linkdead player is the exception -- it has nobody to +## show a roster to, so its body is left for the escape channel to resolve. +func _on_player_died(inst: Instance, peer_id: int) -> void: + if inst.kind != Protocol.InstanceKind.DUNGEON: + return + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + var character_id: String = peer_characters.get(peer_id, "") + if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): + return + var c := store.get_character(account, character_id) + if c == null or not c.active: + return + store.retire_character(account, character_id) + GameLog.info("server", "peer %d lost '%s' at level %d" + % [peer_id, c.display_name, c.level]) + + var p: SimPlayer = inst.world.players.get(peer_id) + if p != null and p.linkdead: + _send_characters(peer_id) + _broadcast_roster() + return + + inst.remove_peer(peer_id) + peer_instance.erase(peer_id) + peer_characters.erase(peer_id) + peer_chunks.erase(peer_id) + _send_characters(peer_id) + _broadcast_roster() + + # --- Roster ----------------------------------------------------------------- ## Tell everyone who is online and where they are, so the hub can show that a diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index 9afa836..76805ed 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -5,9 +5,18 @@ extends RefCounted var peer_id: int = 0 var display_name: String = "player" +## Which character this player is. Set by the instance layer from the account's +## store; the simulation only reads max_hp and colour from it, and never writes +## progression -- experience is banked by ServerRuntime, which owns the store. +var character_id: String = "" +var level: int = Progression.START_LEVEL +var colour := Color.WHITE var pos := Vector2.ZERO var aim: float = 0.0 var hp: int = SimConfig.PLAYER_MAX_HP +## Cached from the character's level, so hit resolution never reaches outside +## the simulation to work out how much health someone has. +var max_hp: int = SimConfig.PLAYER_MAX_HP var alive: bool = true var fire_cooldown: int = 0 ## Ticks of arrival protection left: invulnerable, and unable to shoot. @@ -54,7 +63,8 @@ func can_fire() -> bool: ## SimConfig.SPAWN_GRACE_TICKS for a dungeon. func reset_for_instance(spawn: Vector2, grace: int = 0) -> void: pos = spawn - hp = SimConfig.PLAYER_MAX_HP + max_hp = Progression.max_hp_for_level(level) + hp = max_hp alive = true spawn_grace = grace respawn_lockout = 0 @@ -66,3 +76,13 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void: ## True once the lockout has run out and the hub is available again. func can_request_respawn() -> bool: return not alive and respawn_lockout <= 0 + + +## Adopt a character's stats. Called when a player picks or switches character. +func adopt(c: Character) -> void: + character_id = c.id + display_name = c.display_name + level = c.level + colour = c.colour + max_hp = c.max_hp() + hp = mini(hp, max_hp) diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 8d81ad5..10eca23 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -436,7 +436,9 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void: events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp}) if e.hp <= 0: e.alive = false - events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id}) + # The def id rides along so the instance layer can score it without + # looking up an actor that is about to stop existing. + events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)}) func _damage_boss(amount: int) -> void: @@ -447,7 +449,7 @@ func _damage_boss(amount: int) -> void: events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp}) if boss.hp <= 0: boss.alive = false - events.append({"t": SimEvent.Type.BOSS_DIED}) + events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)}) ## Bullets that died against geometry. A client is only streamed the map near diff --git a/src/ui/character_select.gd b/src/ui/character_select.gd new file mode 100644 index 0000000..b2e7a27 --- /dev/null +++ b/src/ui/character_select.gd @@ -0,0 +1,146 @@ +extends CanvasLayer +## Character roster: pick one, or make one. +## +## Shown when the player has no character in the world -- at first login, and +## again when their last one dies. Permadeath makes this a screen players will +## see repeatedly, so dead characters stay listed rather than vanishing: seeing +## the run you lost is the point of keeping the record. + +signal select_requested(character_id: String) +signal create_requested(character_name: String) + +var _list: VBoxContainer +var _name_field: LineEdit +var _create_button: Button +var _status: Label +var _title: Label +var _known: Array[Dictionary] = [] +var _selected: String = "" + + +func _ready() -> void: + layer = 30 + var root := Control.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(root) + + var scrim := ColorRect.new() + scrim.color = Color(0.03, 0.03, 0.06, 0.92) + scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(scrim) + + var centre := CenterContainer.new() + centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(centre) + + var panel := VBoxContainer.new() + panel.custom_minimum_size = Vector2(460.0, 0.0) + panel.add_theme_constant_override("separation", 8) + centre.add_child(panel) + + _title = Label.new() + _title.text = "CHARACTERS" + _title.add_theme_font_size_override("font_size", 26) + panel.add_child(_title) + + _list = VBoxContainer.new() + _list.add_theme_constant_override("separation", 4) + panel.add_child(_list) + + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0.0, 10.0) + panel.add_child(spacer) + + var row := HBoxContainer.new() + panel.add_child(row) + _name_field = LineEdit.new() + _name_field.placeholder_text = "new character name" + _name_field.max_length = Character.MAX_NAME + _name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(_name_field) + _create_button = Button.new() + _create_button.text = "Create" + _create_button.pressed.connect(_on_create) + row.add_child(_create_button) + + _status = Label.new() + _status.add_theme_font_size_override("font_size", 12) + _status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5)) + panel.add_child(_status) + + visible = false + + +func _on_create() -> void: + var wanted := _name_field.text.strip_edges() + if wanted.is_empty(): + set_status("give the character a name") + return + create_requested.emit(wanted) + _name_field.text = "" + + +func set_status(text: String) -> void: + _status.text = text + + +## Rebuild from the server's roster. Called on every change rather than diffed: +## the list is at most a handful of rows and correctness matters more than +## avoiding a few Control allocations. +func refresh(characters: Array[Dictionary], selected: String) -> void: + _known = characters + _selected = selected + for child in _list.get_children(): + child.queue_free() + + var living := 0 + for c in characters: + if c["active"]: + living += 1 + _list.add_child(_row_for(c)) + + if characters.is_empty(): + var empty := Label.new() + empty.text = "No characters yet. Name one below to begin." + empty.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85)) + _list.add_child(empty) + + var room := living < CharacterStore.MAX_ACTIVE + _create_button.disabled = not room + _name_field.editable = room + _title.text = "CHARACTERS (%d / %d living)" % [living, CharacterStore.MAX_ACTIVE] + if not room: + set_status("%d living characters is the limit -- one must fall first" + % CharacterStore.MAX_ACTIVE) + + +func _row_for(c: Dictionary) -> Control: + var row := HBoxContainer.new() + + # The character's colour, which is its only identity until cosmetics exist. + var swatch := ColorRect.new() + swatch.color = c["colour"] + swatch.custom_minimum_size = Vector2(14.0, 14.0) + row.add_child(swatch) + + var alive: bool = c["active"] + var label := Label.new() + var suffix := "" + if not alive: + suffix = " (dead)" + elif String(c["id"]) == _selected: + suffix = " <- playing" + label.text = " %s level %d %d hp%s" % [ + c["name"], int(c["level"]), int(c["max_hp"]), suffix] + label.add_theme_color_override("font_color", + Color(0.85, 0.9, 1.0) if alive else Color(0.55, 0.4, 0.42)) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(label) + + var play := Button.new() + play.text = "Play" + # A dead character is listed as a record, never as an option. + play.disabled = not alive or String(c["id"]) == _selected + play.pressed.connect(func() -> void: select_requested.emit(String(c["id"]))) + row.add_child(play) + return row diff --git a/src/ui/character_select.gd.uid b/src/ui/character_select.gd.uid new file mode 100644 index 0000000..9d2b90b --- /dev/null +++ b/src/ui/character_select.gd.uid @@ -0,0 +1 @@ +uid://w2dehyfmfm0i diff --git a/src/ui/hud.gd b/src/ui/hud.gd index f10b4a4..968fdce 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -91,8 +91,12 @@ func _status_text() -> String: if client == null: return "connecting..." var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON" - return "%s instance %d hp %d/%d %d fps" % [ - where, client.instance_id, client.my_hp, SimConfig.PLAYER_MAX_HP, + var who := client.current_character() + var name_part := "" + if not who.is_empty(): + name_part = "%s lv %d " % [who["name"], int(who["level"])] + return "%s%s instance %d hp %d/%d %d fps" % [ + name_part, where, client.instance_id, client.my_hp, client.my_max_hp, Engine.get_frames_per_second()] @@ -110,8 +114,9 @@ func _draw_hud() -> void: if client == null: return var origin := Vector2(MARGIN, MARGIN + 28.0) - _bar(origin, float(client.my_hp) / float(SimConfig.PLAYER_MAX_HP), + _bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)), Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18)) + _draw_xp_bar(origin + Vector2(0.0, BAR_H + 3.0)) if client.my_escaping: _bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape, @@ -144,6 +149,19 @@ func _draw_hud() -> void: Color(1.0, 0.2, 0.25, 0.18 * _hit_flash)) +## A thin bar under health: progress toward the next level, and the level +## itself. Drawn from the server's numbers, never recomputed locally. +func _draw_xp_bar(at: Vector2) -> void: + var who := client.current_character() + if who.is_empty(): + return + var level := int(who["level"]) + var progress: float = who["progress"] if level < Progression.MAX_LEVEL else 1.0 + _canvas.draw_rect(Rect2(at, Vector2(BAR_W, 4.0)), Color(0.1, 0.12, 0.18)) + _canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 4.0)), + Color(0.6, 0.55, 1.0) if level < Progression.MAX_LEVEL else Color(1.0, 0.85, 0.4)) + + ## Shown after the boss dies, so the victory lap has a visible clock on it. func _draw_cleared_countdown() -> void: if client.cleared_countdown >= Protocol.COUNTDOWN_NONE: diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 8f84882..3b3fdbf 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -7,8 +7,12 @@ extends Node2D @onready var hud: CanvasLayer = $HUD @onready var menu: CanvasLayer = $GameMenu @onready var sfx: Node = $Sfx +@onready var characters: CanvasLayer = $CharacterSelect var _bound: ClientRuntime = null +## Opened deliberately from the menu, as opposed to forced open by having no +## character to play. +var _roster_open: bool = false func _ready() -> void: @@ -17,6 +21,8 @@ func _ready() -> void: menu.return_to_hub_requested.connect(_on_return_to_hub) menu.disconnect_requested.connect(_on_disconnect) hud.respawn_pressed.connect(_on_respawn_pressed) + characters.select_requested.connect(func(id: String) -> void: Net.select_character(id)) + characters.create_requested.connect(func(n: String) -> void: Net.create_character(n)) var _screen_centre := Vector2.ZERO @@ -48,6 +54,13 @@ func _process(_delta: float) -> void: _bound.shot_fired.connect(func() -> void: sfx.play(Art.SFX_SHOOT, -14.0)) _bound.enemy_died.connect(func() -> void: sfx.play(Art.SFX_ENEMY_DEATH, -8.0)) _bound.boss_died.connect(func() -> void: sfx.play(Art.SFX_BOSS_DEATH, -2.0)) + _bound.characters_changed.connect(_refresh_characters) + _bound.select_failed.connect(func(why: String) -> void: characters.set_status(why)) + _refresh_characters() + # The roster screen is shown exactly when there is nothing to play: first + # login, or after the last living character died. + if _bound != null: + characters.visible = _bound.needs_character() or _roster_open _follow_camera() menu.set_in_dungeon(_bound != null and _bound.instance_kind == Protocol.InstanceKind.DUNGEON) @@ -66,6 +79,11 @@ func _on_disconnect() -> void: ## Sets the intent bit; the server decides whether the lockout has expired. +func _refresh_characters() -> void: + if _bound != null: + characters.refresh(_bound.characters, _bound.selected_character) + + func _on_respawn_pressed() -> void: if _bound != null: _bound.request_respawn = true diff --git a/src/view/world_view.gd b/src/view/world_view.gd index f5ef422..fb0acb6 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -197,8 +197,10 @@ func _draw_boss() -> void: func _draw_remote_player(p: Dictionary) -> void: var flags := int(p["flags"]) var alive := (flags & Protocol.F_ALIVE) != 0 - var col := COL_REMOTE if alive else COL_DEAD - _draw_ship(p["pos"], p["aim"], col, alive) + # Each character's own colour, which is the only thing telling party + # members apart until there is a cosmetic system. + var col: Color = p["colour"] if alive else COL_DEAD + _draw_ship(p["pos"], p["aim"], col, alive, false, Art.PLAYER_ANCHOR) if (flags & Protocol.F_SPAWN_GRACE) != 0: _draw_grace_ring(p["pos"]) if (flags & Protocol.F_ESCAPING) != 0: @@ -209,7 +211,9 @@ func _draw_local_player() -> void: if not client.my_alive: _draw_ship(client.predicted_pos, client.aim, COL_DEAD, false) return - _draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true, client.is_moving()) + var mine := client.current_character() + var my_colour: Color = mine["colour"] if not mine.is_empty() else COL_LOCAL + _draw_ship(client.predicted_pos, client.aim, my_colour, true, client.is_moving()) if client.my_spawn_grace: _draw_grace_ring(client.predicted_pos) if client.my_escaping: @@ -228,12 +232,16 @@ func _draw_grace_ring(pos: Vector2) -> void: ## is deliberately larger than PLAYER_RADIUS, the hitbox used for hits. A bullet ## can visibly clip the sprite without landing, which reads as more forgiving of ## latency than the reverse. See sim_config.gd. -func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool = false) -> void: +func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, + moving: bool = false, _unused: Vector2 = Vector2.ZERO) -> void: var strip := Art.PLAYER_RUN if moving else Art.PLAYER_IDLE var src := Art.frame(strip, Art.anim_frame(_anim_time, 0)) # Faces the way you aim, which is the whole point of a twin-stick. var flip := absf(wrapf(aim, -PI, PI)) > PI * 0.5 - _draw_sprite(Art.TILESET, src, pos, col if not alive else Color.WHITE, flip, + # Tinted rather than replaced: modulate keeps the sprite's shading, so a + # character reads as coloured armour rather than a flat silhouette. + var tint := col if alive else COL_DEAD + _draw_sprite(Art.TILESET, src, pos, tint.lerp(Color.WHITE, 0.35), flip, Art.PLAYER_ANCHOR) if alive: var dir := Vector2.RIGHT.rotated(aim) diff --git a/tests/unit/test_character_store.gd b/tests/unit/test_character_store.gd new file mode 100644 index 0000000..8161eac --- /dev/null +++ b/tests/unit/test_character_store.gd @@ -0,0 +1,150 @@ +extends GutTest +## Characters and their persistence. These matter more than most tests here: +## the whole point of a character is that it survives, and losing one to a bug +## is not something a player can be compensated for. + +var store: CharacterStore +const ACC := 1234567890123 +const OTHER := 9876543210987 + + +func before_each() -> void: + # A real file in a throwaway location, so the JSON round trip is exercised + # rather than mocked -- serialisation is exactly where this would break. + store = CharacterStore.new("user://test_characters_%d.json" % randi()) + store.load_from_disk() + + +func after_each() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(store._path)) + + +func test_a_new_account_has_nothing() -> void: + assert_eq(store.characters_for(ACC).size(), 0) + assert_null(store.last_played(ACC)) + assert_true(store.can_create(ACC)) + + +func test_creating_and_finding_a_character() -> void: + var c := store.create_character(ACC, "Ada") + assert_not_null(c) + assert_eq(c.display_name, "Ada") + assert_eq(c.level, Progression.START_LEVEL) + assert_eq(c.max_hp(), SimConfig.PLAYER_MAX_HP) + assert_true(c.active) + assert_eq(store.get_character(ACC, c.id).id, c.id) + + +func test_accounts_are_isolated() -> void: + var mine := store.create_character(ACC, "Mine") + store.create_character(OTHER, "Theirs") + assert_eq(store.characters_for(ACC).size(), 1) + assert_null(store.get_character(OTHER, mine.id), + "one account must never see another's characters") + + +func test_the_living_character_limit() -> void: + for i in CharacterStore.MAX_ACTIVE: + assert_not_null(store.create_character(ACC, "c%d" % i)) + assert_false(store.can_create(ACC)) + assert_null(store.create_character(ACC, "one too many")) + + +## The reason the cap counts living characters only: five deaths must not lock +## a player out of their own account permanently. +func test_retired_characters_do_not_occupy_a_slot() -> void: + var first := store.create_character(ACC, "doomed") + for i in CharacterStore.MAX_ACTIVE - 1: + store.create_character(ACC, "c%d" % i) + assert_false(store.can_create(ACC)) + store.retire_character(ACC, first.id) + assert_true(store.can_create(ACC), "a death should free the slot it held") + assert_eq(store.characters_for(ACC).size(), CharacterStore.MAX_ACTIVE, + "but the record itself must still be there") + + +func test_death_retires_rather_than_deletes() -> void: + var c := store.create_character(ACC, "Grace") + store.retire_character(ACC, c.id) + var found := store.get_character(ACC, c.id) + assert_not_null(found, "the record is kept for archival") + assert_false(found.active) + assert_gt(found.died_unix, 0, "and records when it happened") + + +func test_last_played_prefers_the_last_one_and_skips_the_dead() -> void: + var a := store.create_character(ACC, "A") + var b := store.create_character(ACC, "B") + store.set_last_played(ACC, a.id) + assert_eq(store.last_played(ACC).id, a.id) + store.retire_character(ACC, a.id) + assert_eq(store.last_played(ACC).id, b.id, + "a dead character must not be auto-selected on login") + + +func test_everything_survives_a_save_and_reload() -> void: + var c := store.create_character(ACC, "Persistent") + store.grant_xp(ACC, c.id, Progression.xp_to_next(1) + 5) + store.set_last_played(ACC, c.id) + var dead := store.create_character(ACC, "Departed") + store.retire_character(ACC, dead.id) + + var reloaded := CharacterStore.new(store._path) + assert_true(reloaded.load_from_disk()) + var got := reloaded.get_character(ACC, c.id) + assert_not_null(got, "the character survived the round trip") + assert_eq(got.display_name, "Persistent") + assert_eq(got.level, 2) + assert_eq(got.colour.to_html(false), c.colour.to_html(false)) + assert_eq(reloaded.last_played(ACC).id, c.id) + assert_false(reloaded.get_character(ACC, dead.id).active, + "and so did the fact that one of them died") + + +## Account ids are 64-bit; JSON numbers are doubles and would round them. They +## are written as decimal strings for exactly this reason. +func test_large_account_ids_survive_the_round_trip() -> void: + var big := 9007199254740993 + store.create_character(big, "Precise") + var reloaded := CharacterStore.new(store._path) + reloaded.load_from_disk() + assert_eq(reloaded.characters_for(big).size(), 1, + "a 64-bit account id must not be mangled by JSON") + + +func test_a_corrupt_file_is_refused_rather_than_overwritten() -> void: + assert_not_null(store.create_character(ACC, "Valuable")) + var f := FileAccess.open(store._path, FileAccess.WRITE) + f.store_string("{ this is not json") + f.close() + var reloaded := CharacterStore.new(store._path) + assert_false(reloaded.load_from_disk(), + "a bad file must fail loudly; silently starting empty would then " + + "save over every character on the next write") + + +func test_granting_experience_reports_levels_gained() -> void: + var c := store.create_character(ACC, "Riser") + assert_eq(store.grant_xp(ACC, c.id, 1), 0, "not enough for a level") + assert_gte(store.grant_xp(ACC, c.id, Progression.total_xp_for_level(3)), 1, + "crossing a threshold reports the levels") + var after := store.get_character(ACC, c.id) + assert_eq(after.level, Progression.level_for_xp(after.total_xp), + "level and experience must never disagree") + + +func test_a_dead_character_earns_nothing() -> void: + var c := store.create_character(ACC, "Late") + store.retire_character(ACC, c.id) + store.grant_xp(ACC, c.id, 5000) + assert_eq(store.get_character(ACC, c.id).total_xp, 0) + + +## Names are shown to other players, so they are clamped at the boundary rather +## than trusted anywhere downstream. +func test_names_are_sanitised() -> void: + assert_eq(Character.sanitize_name(" "), "adventurer", "blank names get a default") + assert_lte(Character.sanitize_name("x".repeat(500)).length(), Character.MAX_NAME) + var with_control := "ab" + String.chr(7) + String.chr(10) + "cd" + assert_eq(Character.sanitize_name(with_control), "abcd", + "control characters would let a name break the HUD's layout") diff --git a/tests/unit/test_character_store.gd.uid b/tests/unit/test_character_store.gd.uid new file mode 100644 index 0000000..875ac2f --- /dev/null +++ b/tests/unit/test_character_store.gd.uid @@ -0,0 +1 @@ +uid://dxdd52gmk2ki4 diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index d594e2d..5fac95f 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -161,3 +161,60 @@ func test_cleared_countdown_round_trips() -> void: func test_countdown_defaults_to_not_applicable() -> void: var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) assert_eq(int(snap["cleared_countdown"]), Protocol.COUNTDOWN_NONE) + + +# --- Characters ------------------------------------------------------------- + +func test_character_roster_round_trips() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 5 + var a := Character.create("Ada", rng) + a.grant_xp(Progression.total_xp_for_level(4)) + var b := Character.create("Departed", rng) + b.retire() + var out := NetCodec.decode_characters( + NetCodec.encode_characters([a, b] as Array[Character], a.id)) + assert_eq(String(out["selected"]), a.id) + var chars: Array = out["characters"] + assert_eq(chars.size(), 2) + assert_eq(String(chars[0]["name"]), "Ada") + assert_eq(int(chars[0]["level"]), a.level) + assert_eq(int(chars[0]["max_hp"]), a.max_hp(), + "the roster shows each character's own health ceiling") + assert_true(chars[0]["active"]) + assert_false(chars[1]["active"], "a dead character is listed, not hidden") + + +## Colour is a character's only identity until cosmetics exist, so it has to +## survive the wire exactly rather than approximately. +func test_character_colour_survives_the_wire() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 11 + var c := Character.create("Hue", rng) + var out := NetCodec.decode_characters( + NetCodec.encode_characters([c] as Array[Character], "")) + var got: Color = out["characters"][0]["colour"] + assert_eq(got.to_rgba32(), c.colour.to_rgba32()) + + +func test_an_empty_character_list_is_safe() -> void: + var out := NetCodec.decode_characters( + NetCodec.encode_characters([] as Array[Character], "")) + assert_eq((out["characters"] as Array).size(), 0) + assert_eq(NetCodec.decode_characters(PackedByteArray())["characters"].size(), 0) + + +## Max health is per-character now, so a snapshot that assumed a constant would +## draw every levelled player's bar wrong. +func test_the_snapshot_carries_each_players_own_max_health() -> void: + var p: SimPlayer = world.players[42] + p.level = 5 + p.max_hp = Progression.max_hp_for_level(5) + p.hp = 42 + p.colour = Color(0.2, 0.6, 0.9) + var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) + var rec: Dictionary = snap["players"][0] + assert_eq(int(rec["hp"]), 42) + assert_eq(int(rec["max_hp"]), Progression.max_hp_for_level(5)) + assert_almost_eq((rec["colour"] as Color).b, 0.9, 0.02, + "party members are told apart by colour, so it rides the snapshot") diff --git a/tests/unit/test_progression.gd b/tests/unit/test_progression.gd new file mode 100644 index 0000000..9a94b08 --- /dev/null +++ b/tests/unit/test_progression.gd @@ -0,0 +1,78 @@ +extends GutTest +## The XP curve and what a level is worth. Pure arithmetic, so the shape of +## progression can be argued about without running a server. + + +func test_level_one_is_the_base_health() -> void: + assert_eq(Progression.max_hp_for_level(1), SimConfig.PLAYER_MAX_HP) + + +func test_each_level_adds_ten_health() -> void: + for level in range(1, Progression.MAX_LEVEL): + assert_eq(Progression.max_hp_for_level(level + 1) + - Progression.max_hp_for_level(level), Progression.HP_PER_LEVEL) + + +func test_the_cap_is_reachable_and_final() -> void: + assert_eq(Progression.max_hp_for_level(Progression.MAX_LEVEL), + SimConfig.PLAYER_MAX_HP + (Progression.MAX_LEVEL - 1) * Progression.HP_PER_LEVEL) + # Past the cap nothing further is gained, however much xp arrives. + assert_eq(Progression.max_hp_for_level(999), + Progression.max_hp_for_level(Progression.MAX_LEVEL)) + assert_eq(Progression.xp_to_next(Progression.MAX_LEVEL), 0) + + +func test_level_is_derived_consistently_from_experience() -> void: + # The two directions must agree exactly, or a character's level could + # disagree with the experience that earned it. + for level in range(1, Progression.MAX_LEVEL + 1): + var at := Progression.total_xp_for_level(level) + assert_eq(Progression.level_for_xp(at), level, + "exactly enough xp for level %d should be level %d" % [level, level]) + # Skipped at level 1: there is no level 0 to fall short into, and + # negative lifetime xp is not a state a character can be in. + if level > Progression.START_LEVEL: + assert_eq(Progression.level_for_xp(at - 1), level - 1, + "one short of level %d should still be level %d" % [level, level - 1]) + + +func test_experience_never_exceeds_the_cap() -> void: + assert_eq(Progression.level_for_xp(99999999), Progression.MAX_LEVEL) + assert_eq(Progression.level_progress(99999999), 1.0, + "a capped character's bar should read full, not empty") + + +func test_progress_runs_from_zero_to_one_within_a_level() -> void: + var at := Progression.total_xp_for_level(3) + assert_almost_eq(Progression.level_progress(at), 0.0, 0.001) + assert_almost_eq(Progression.level_progress(at + Progression.xp_to_next(3) / 2), 0.5, 0.05) + + +func test_the_requirement_grows_with_level() -> void: + for level in range(1, Progression.MAX_LEVEL - 1): + assert_gt(Progression.xp_to_next(level + 1), Progression.xp_to_next(level), + "later levels must cost more, or the curve is flat at the end") + + +## The brief's one concrete pacing requirement: a first full clear should be a +## bit more than the first level-up needs. +func test_a_first_full_dungeon_clear_slightly_exceeds_the_first_level() -> void: + # A depth-1 dungeon holds roughly a dozen enemies plus the boss. + var trash := 12 * Progression.xp_for_enemy(Content.ENEMY_DRIFTER) + var clear := trash + Progression.xp_for_boss(Content.BOSS_WARDEN) + var needed := Progression.xp_to_next(1) + assert_gt(clear, needed, "a full clear should get you the first level") + assert_lt(clear, needed * 2, + "but not two levels, or the first run outpaces the curve") + + +func test_unknown_enemies_award_nothing_rather_than_a_default() -> void: + assert_eq(Progression.xp_for_enemy(&"no_such_enemy"), 0) + assert_eq(Progression.xp_for_enemy(Content.ENEMY_DUMMY), 0, + "the practice target must not be an xp farm") + + +func test_experience_below_the_first_level_is_still_level_one() -> void: + assert_eq(Progression.level_for_xp(0), Progression.START_LEVEL) + assert_eq(Progression.level_for_xp(-500), Progression.START_LEVEL, + "a nonsensical total must clamp rather than produce a level 0 character") diff --git a/tests/unit/test_progression.gd.uid b/tests/unit/test_progression.gd.uid new file mode 100644 index 0000000..f647520 --- /dev/null +++ b/tests/unit/test_progression.gd.uid @@ -0,0 +1 @@ +uid://cllapwggk4ean diff --git a/tools/diag_progression.gd b/tools/diag_progression.gd new file mode 100644 index 0000000..8ea956a --- /dev/null +++ b/tools/diag_progression.gd @@ -0,0 +1,137 @@ +extends Node +## End-to-end check of the progression wiring: kill -> experience -> level -> +## health, and death -> character retired -> roster offered. +## +## godot --headless --path . res://tools/diag_progression.tscn +## +## Runs as a scene because ServerRuntime needs the Net autoload, which a +## --script run does not have. Exits non-zero on any failure, so it can gate. +## +## The store side is unit-tested in isolation; what this covers is the wiring +## between the simulation's events and the account that banks them, which is +## exactly the part a bot cannot be relied on to exercise (bots are poor shots). + +const STORE_PATH := "user://diag_progression.json" + +var _fails: Array[String] = [] +var _step: int = 0 +var _srv: ServerRuntime +var _account: int = 424242 +var _character: Character + + +func _ready() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) + GameOpts.bot_client = true + GameOpts.account_override = _account + if Net.host(27401) != OK: + push_error("could not host") + get_tree().quit(1) + return + _srv = Net.server + _srv.store = CharacterStore.new(STORE_PATH) + _srv.store.load_from_disk() + Net.start_local_client() + + +func _check(ok: bool, what: String) -> void: + if ok: + print(" ok %s" % what) + else: + print(" FAIL %s" % what) + _fails.append(what) + + +func _physics_process(_delta: float) -> void: + _step += 1 + if _step == 20: + _character = _srv.store.last_played(_account) + _check(_character != null, "a character exists after login") + if _character == null: + _finish() + return + _check(_character.level == 1, "starts at level 1") + _check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == _character.id, + "the peer is playing it") + + elif _step == 40: + # Into a dungeon, which is where experience is earned. + _srv._send_to_dungeon(Net.LOCAL_PEER) + + elif _step == 60: + var inst := _srv.instance_of(Net.LOCAL_PEER) + _check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON, + "moved into a dungeon") + if inst == null: + _finish() + return + var before: int = _srv.store.get_character(_account, _character.id).total_xp + # Kill something the way a hit would: through the world's own damage + # path, so the event carries the def id the scorer reads. + var victim: SimEnemy = null + for e in inst.world.enemies.values(): + if e.alive: + victim = e + break + if victim == null: + _check(false, "the dungeon had a living enemy to kill") + _finish() + return + victim.hp = 1 + inst.world._damage_enemy(victim, 100) + _srv._dispatch_events(inst) + var after: int = _srv.store.get_character(_account, _character.id).total_xp + _check(after > before, "killing an enemy awards experience (%d -> %d)" % [before, after]) + + elif _step == 80: + # Enough experience to cross a level boundary, and check health follows. + var inst := _srv.instance_of(Net.LOCAL_PEER) + var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] + var hp_before := p.max_hp + _srv._grant_xp(Net.LOCAL_PEER, Progression.total_xp_for_level(3)) + var c := _srv.store.get_character(_account, _character.id) + _check(c.level >= 2, "experience produces levels (now %d)" % c.level) + _check(p.level == c.level, "the player in the world levels with it") + _check(p.max_hp > hp_before, + "max health follows the level (%d -> %d)" % [hp_before, p.max_hp]) + _check(p.max_hp == Progression.max_hp_for_level(c.level), + "and matches the curve exactly") + + elif _step == 90: + # Stop behaving like a bot before the kill. A bot auto-creates a + # replacement the instant it sees an empty roster -- correct in play, + # but it would mask exactly what the next steps are checking. + GameOpts.bot_client = false + + elif _step == 100: + var inst := _srv.instance_of(Net.LOCAL_PEER) + var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] + p.spawn_grace = 0 + p.hp = 1 + inst.world._damage_player(p, 9999) + _srv._dispatch_events(inst) + var c := _srv.store.get_character(_account, _character.id) + _check(c != null, "the dead character is kept, not deleted") + _check(not c.active, "and is marked inactive") + _check(c.died_unix > 0, "with a time of death recorded") + _check(_srv.store.active_characters(_account).is_empty(), + "no living characters remain") + + elif _step == 120: + # With nothing alive, the player must be left with no character rather + # than silently resurrected into the dead one. + _check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == "", + "the player is left without a character to play") + _check(Net.client.needs_character(), + "so the client shows the roster screen") + _finish() + + +func _finish() -> void: + print("---") + if _fails.is_empty(): + print("PROGRESSION_OK") + else: + print("PROGRESSION_FAIL (%d)" % _fails.size()) + Net.shutdown() + get_tree().quit(0 if _fails.is_empty() else 1) diff --git a/tools/diag_progression.gd.uid b/tools/diag_progression.gd.uid new file mode 100644 index 0000000..2abcf8a --- /dev/null +++ b/tools/diag_progression.gd.uid @@ -0,0 +1 @@ +uid://mvqw6wolxjoe diff --git a/tools/diag_progression.tscn b/tools/diag_progression.tscn new file mode 100644 index 0000000..978e04e --- /dev/null +++ b/tools/diag_progression.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/diag_progression.gd" id="1"] + +[node name="DiagProgression" type="Node"] +script = ExtResource("1") diff --git a/tools/smoke.sh b/tools/smoke.sh index 3d49d51..76d6ca7 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -32,7 +32,10 @@ cleanup() { trap cleanup EXIT echo "smoke: server on port $PORT, logs in $OUT" +# A scratch character store: without it a rerun resumes the characters the +# previous run created, and "a character was created" stops being true. "$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \ + --store "$OUT/characters.json" \ > "$OUT/server.log" 2>&1 & PIDS+=($!) @@ -46,7 +49,8 @@ fi for n in 1 2; do "$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ - --name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 & + --name "bot$n" --account "$((7000 + n))" \ + --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 & PIDS+=($!) sleep 0.4 done @@ -57,7 +61,7 @@ done # SIGKILL below. If someone ever adds a "clean leave" message that bypasses the # channel, this is what catches it. "$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ - --name "leavebot" --leave-after 120 --autoquit "$CLIENT_TICKS" \ + --name "leavebot" --account 7101 --leave-after 120 --autoquit "$CLIENT_TICKS" \ > "$OUT/leavebot.log" 2>&1 & PIDS+=($!) sleep 0.4 @@ -67,7 +71,7 @@ sleep 0.4 # channel it out over the same one second the escape button costs, and only then # forget the peer -- never delete it instantly on socket close. "$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ - --name "dropbot" --autoquit "$CLIENT_TICKS" > "$OUT/dropbot.log" 2>&1 & + --name "dropbot" --account 7102 --autoquit "$CLIENT_TICKS" > "$OUT/dropbot.log" 2>&1 & DROP_PID=$! for _ in $(seq 1 80); do grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null && break @@ -106,8 +110,11 @@ refute() { # refute