Stage 2: accounts, characters, permadeath, levels and experience
ci / verify (push) Successful in 47s

Identity is shaped like Steamworks so swapping to it is one subclass and no
schema change: the client presents an opaque ticket, the server validates it
into a stable 64-bit account id, and nothing downstream sees anything else.
LocalAuthProvider takes any ticket at face value -- insecure on purpose, and
labelled as such everywhere, because the point is the shape rather than the
security. Do not ship it.

Characters persist as JSON keyed by account. Account ids are written as decimal
strings because they are 64-bit and JSON numbers are doubles, which would
silently round them. A corrupt store aborts the server rather than starting
empty: starting empty looks like it worked and then saves over every character
on the first level-up.

Levels 1-15, +10 max health each, level DERIVED from lifetime experience rather
than stored beside it, so a hand-edited save cannot produce a level 12 character
with a level 3's experience. Experience is shared undivided across everyone
alive in the instance -- splitting it would make bringing a friend cost you
progress. A level-up heals by what it added, so gaining one mid-fight is relief
rather than a bar that moved further from full.

Death is permanent and unbinds the character entirely: no "return to the hub as
the character who just died", because the run is over. The record is retired,
never deleted. The five-character cap counts LIVING characters only -- counting
the dead would lock a player out of their own account after five deaths.

Verified by tools/diag_progression.tscn, which drives the real server through
kill -> xp -> level -> health and death -> retire -> roster. The bot smoke test
cannot cover that: bots are poor shots and rarely kill anything. Writing it
caught two real ordering bugs -- the death event was dispatched before the
payload that tells the player they died, and the dead character stayed bound to
the peer.

Also added --account and --store so several clients and test runs can coexist
on one machine. The smoke test now uses a scratch store; without it a rerun
resumed the previous run's characters and "a character was created" quietly
stopped being true.

193 tests. check.sh, test.sh, smoke.sh, diag_progression and diag_prediction
all pass.
This commit is contained in:
2026-09-04 00:44:34 +02:00
parent ff5e527ad4
commit 4765bbce28
36 changed files with 1613 additions and 34 deletions
+9
View File
@@ -32,6 +32,8 @@ Everything after `--` goes to `GameOpts.parse()`:
| `--autoquit N` | Quit after N physics ticks. | | `--autoquit N` | Quit after N physics ticks. |
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. | | `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
| `--depth N` | Server-side: depth of new dungeons, which drives map size. | | `--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. | | `--verbose` / `--quiet` | Log level. |
A change is done when `check.sh`, `test.sh` and — if it touched networking, 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_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. | | `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/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/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. | | `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
| `src/instances/` | Lobby hub and dungeon runs. | | `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 - **Bullet speed must stay under one tile per tick.** Wall collision samples
position once per tick, so anything faster tunnels. Pinned by position once per tick, so anything faster tunnels. Pinned by
`test_bullet_speeds_stay_below_the_tunnelling_threshold`. `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 - **No contact damage.** Every enemy threatens through bullets only; touching
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
an emitter. an emitter.
+35
View File
@@ -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 **Permadeath.** Death marks a character inactive — never deleted, for archival
and troubleshooting — and the player picks another character or creates one. 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.
+29 -4
View File
@@ -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. | | **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). | | 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 115, +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 Depends on nothing in Stage 1 except a place to stand. Blocked only on the
identity layer, which is decided but unbuilt. 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 | | 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 | | Feature | Notes |
| --- | --- | | --- | --- |
| Small always-on-screen inventory | Slot count unspecified. | | Small always-on-screen inventory | Slot count unspecified. |
+62 -4
View File
@@ -22,6 +22,10 @@ var server: ServerRuntime = null
var client: ClientRuntime = null var client: ClientRuntime = null
var last_error: String = "" 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: func _set_state(s: State) -> void:
state = s state = s
@@ -116,7 +120,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
func _on_connected() -> void: func _on_connected() -> void:
_set_state(State.ONLINE) _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: func _on_connect_failed() -> void:
@@ -142,7 +146,7 @@ func start_local_client() -> void:
client = ClientRuntime.new() client = ClientRuntime.new()
client.name = "Client" client.name = "Client"
add_child(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: 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) 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: func send_roster(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id): if _is_local(peer_id):
client.on_roster(data) client.on_roster(data)
@@ -208,6 +226,20 @@ func send_reject(peer_id: int, reason: String) -> void:
s_reject.rpc_id(peer_id, reason) 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: func send_input(data: PackedByteArray) -> void:
if server != null: if server != null:
server.on_input(LOCAL_PEER, data) # listen server: no transport at all server.on_input(LOCAL_PEER, data) # listen server: no transport at all
@@ -218,10 +250,24 @@ func send_input(data: PackedByteArray) -> void:
# --- Client -> server ------------------------------------------------------- # --- Client -> server -------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable", 1) @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: if server == null:
return 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) @rpc("any_peer", "call_remote", "unreliable_ordered", 4)
@@ -262,6 +308,18 @@ func s_reject(reason: String) -> void:
_set_state(State.FAILED) _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) @rpc("authority", "call_remote", "reliable", 1)
func s_roster(data: PackedByteArray) -> void: func s_roster(data: PackedByteArray) -> void:
if client != null: if client != null:
+17
View File
@@ -30,6 +30,15 @@ static var boss_rush: bool = false
## Dev switch: depth of newly opened dungeons, which drives map size. Depth ## Dev switch: depth of newly opened dungeons, which drives map size. Depth
## progression is a later-stage concern; this makes big maps testable now. ## progression is a later-stage concern; this makes big maps testable now.
static var dungeon_depth: int = 1 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 static var parsed: bool = false
@@ -54,6 +63,14 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
listen = true listen = true
"--boss-rush": "--boss-rush":
boss_rush = true 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": "--depth":
i += 1 i += 1
if i < argv.size(): if i < argv.size():
+5 -1
View File
@@ -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/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"] [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/hud.gd" id="4"]
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"] [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/view/sfx.gd" id="6"]
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
[node name="Game" type="Node2D"] [node name="Game" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
@@ -25,3 +26,6 @@ script = ExtResource("5")
[node name="Sfx" type="Node" parent="."] [node name="Sfx" type="Node" parent="."]
script = ExtResource("6") script = ExtResource("6")
[node name="CharacterSelect" type="CanvasLayer" parent="."]
script = ExtResource("7")
+28
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://fteslbujpkuo
+104
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://tuerwia65i3i
+175
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://becirpnytb1b8
+63
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://hxyq22vllwgt
+89
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cjtfbpej8ywwf
+58
View File
@@ -15,6 +15,9 @@ signal local_hit(damage: int)
signal shot_fired signal shot_fired
signal enemy_died signal enemy_died
signal boss_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 my_peer: int = 0
var instance_id: int = 0 var instance_id: int = 0
@@ -38,6 +41,9 @@ var _last_move := Vector2.ZERO
# Authoritative mirror of the local player. # Authoritative mirror of the local player.
var my_hp: int = SimConfig.PLAYER_MAX_HP 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_alive: bool = true
var my_escape: float = 0.0 var my_escape: float = 0.0
var my_escaping: bool = false 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. ## Who is online and where, for the hub's player list. Server-pushed.
var roster: Array[Dictionary] = [] 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 ## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state. ## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE 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) 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: func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data) roster = NetCodec.decode_roster(data)
hud_dirty.emit() hud_dirty.emit()
@@ -301,6 +358,7 @@ func on_snapshot(data: PackedByteArray) -> void:
## land where the client should actually be right now. ## land where the client should actually be right now.
func _reconcile(rec: Dictionary) -> void: func _reconcile(rec: Dictionary) -> void:
my_hp = int(rec["hp"]) my_hp = int(rec["hp"])
my_max_hp = int(rec["max_hp"])
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0 my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0 my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0 my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
+57
View File
@@ -45,6 +45,10 @@ static func encode_snapshot(world: SimWorld,
b.put_float(p.pos.y) b.put_float(p.pos.y)
b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536)) b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536))
b.put_u16(clampi(p.hp, 0, 65535)) 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 var flags := 0
if p.alive: if p.alive:
flags |= Protocol.F_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)) 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. # Echoed so the owning client knows how far to rewind when reconciling.
b.put_u32(p.last_input_tick) 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] = [] var live_enemies: Array[SimEnemy] = []
for e in world.enemies.values(): 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()), "pos": Vector2(b.get_float(), b.get_float()),
"aim": float(b.get_u16()) / 65536.0 * TAU, "aim": float(b.get_u16()) / 65536.0 * TAU,
"hp": b.get_u16(), "hp": b.get_u16(),
"max_hp": b.get_u16(),
"flags": b.get_u8(), "flags": b.get_u8(),
"escape": float(b.get_u8()) / 255.0, "escape": float(b.get_u8()) / 255.0,
"respawn_wait": float(b.get_u8()) / 10.0, "respawn_wait": float(b.get_u8()) / 10.0,
"last_input_tick": b.get_u32(), "last_input_tick": b.get_u32(),
"colour": Color.hex(b.get_u32()),
}) })
var ecount := b.get_u16() 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]) map.apply_chunk(id, b.get_data(n)[1])
applied += 1 applied += 1
return applied 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}
+7 -1
View File
@@ -10,7 +10,9 @@ extends RefCounted
## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts ## 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 ## 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. ## 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 DEFAULT_PORT := 27015
const MAX_CLIENTS := 32 const MAX_CLIENTS := 32
@@ -25,6 +27,10 @@ const CHANNEL_COUNT := 8
enum InstanceKind { LOBBY, DUNGEON } 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. ## Player flags packed into the snapshot's per-player byte.
const F_ALIVE := 1 const F_ALIVE := 1
const F_ESCAPING := 4 const F_ESCAPING := 4
+203 -8
View File
@@ -11,6 +11,14 @@ extends Node
var instances: Dictionary[int, Instance] = {} var instances: Dictionary[int, Instance] = {}
var peer_instance: Dictionary[int, int] = {} var peer_instance: Dictionary[int, int] = {}
var peer_names: Dictionary[int, String] = {} 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 ## Map chunks each peer has been sent, per peer. Reset on every instance
## transfer -- knowledge of one dungeon must not carry into the next. ## transfer -- knowledge of one dungeon must not carry into the next.
var peer_chunks: Dictionary[int, Dictionary] = {} var peer_chunks: Dictionary[int, Dictionary] = {}
@@ -21,6 +29,14 @@ var _snapshot_phase: int = 0
func _ready() -> void: 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()) lobby = Instance.make_lobby(_take_instance_id())
instances[lobby.id] = lobby instances[lobby.id] = lobby
GameLog.info("server", "lobby instance %d up" % lobby.id) 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. # inst.peers and would otherwise change the list mid-broadcast.
var to_lobby: Array[int] = [] var to_lobby: Array[int] = []
var to_dungeon: Array[int] = [] var to_dungeon: Array[int] = []
var died: Array[int] = []
for ev in events: for ev in events:
match int(ev["t"]): 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: SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED:
var peer := int(ev["peer"]) var peer := int(ev["peer"])
if not to_lobby.has(peer): if not to_lobby.has(peer):
@@ -107,6 +133,8 @@ func _dispatch_events(inst: Instance) -> void:
continue continue
Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer)) 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: for peer in to_lobby:
_send_to_lobby(peer) _send_to_lobby(peer)
for peer in to_dungeon: for peer in to_dungeon:
@@ -145,6 +173,8 @@ func _forget_peer(peer_id: int) -> void:
peer_instance.erase(peer_id) peer_instance.erase(peer_id)
peer_names.erase(peer_id) peer_names.erase(peer_id)
peer_chunks.erase(peer_id) peer_chunks.erase(peer_id)
peer_accounts.erase(peer_id)
peer_characters.erase(peer_id)
_broadcast_roster() _broadcast_roster()
@@ -158,22 +188,98 @@ func _release_linkdead(peer_id: int, inst: Instance) -> void:
_forget_peer(peer_id) _forget_peer(peer_id)
func on_hello(peer_id: int, version: int, display_name: String) -> void: ## Handshake: validate the ticket into an account, then offer that account's
if peer_names.has(peer_id): ## 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 return # a second hello from the same peer is either a bug or an attack
if version != Protocol.VERSION: if version != Protocol.VERSION:
GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, 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.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
Net.kick(peer_id) Net.kick(peer_id)
return return
# Never trust a client-supplied string for anything but display. var account := Net.auth.validate(ticket)
var clean := display_name.strip_edges().substr(0, 24) if account == AuthProvider.NO_ACCOUNT:
if clean.is_empty(): GameLog.warn("server", "peer %d failed authentication" % peer_id)
clean = "player%d" % peer_id Net.send_reject(peer_id, "authentication failed")
peer_names[peer_id] = clean Net.kick(peer_id)
return
peer_accounts[peer_id] = account
Net.send_welcome(peer_id) 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) _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() _broadcast_roster()
@@ -228,6 +334,23 @@ func _send_to_lobby(peer_id: int) -> void:
if p != null and p.linkdead: if p != null and p.linkdead:
_release_linkdead(peer_id, from) _release_linkdead(peer_id, from)
return 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) GameLog.info("server", "peer %d escaped to lobby" % peer_id)
_transfer(peer_id, lobby) _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)) 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 ----------------------------------------------------------------- # --- Roster -----------------------------------------------------------------
## Tell everyone who is online and where they are, so the hub can show that a ## Tell everyone who is online and where they are, so the hub can show that a
+21 -1
View File
@@ -5,9 +5,18 @@ extends RefCounted
var peer_id: int = 0 var peer_id: int = 0
var display_name: String = "player" 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 pos := Vector2.ZERO
var aim: float = 0.0 var aim: float = 0.0
var hp: int = SimConfig.PLAYER_MAX_HP 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 alive: bool = true
var fire_cooldown: int = 0 var fire_cooldown: int = 0
## Ticks of arrival protection left: invulnerable, and unable to shoot. ## 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. ## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
func reset_for_instance(spawn: Vector2, grace: int = 0) -> void: func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
pos = spawn pos = spawn
hp = SimConfig.PLAYER_MAX_HP max_hp = Progression.max_hp_for_level(level)
hp = max_hp
alive = true alive = true
spawn_grace = grace spawn_grace = grace
respawn_lockout = 0 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. ## True once the lockout has run out and the hub is available again.
func can_request_respawn() -> bool: func can_request_respawn() -> bool:
return not alive and respawn_lockout <= 0 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)
+4 -2
View File
@@ -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}) events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if e.hp <= 0: if e.hp <= 0:
e.alive = false 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: 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}) events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if boss.hp <= 0: if boss.hp <= 0:
boss.alive = false 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 ## Bullets that died against geometry. A client is only streamed the map near
+146
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://w2dehyfmfm0i
+21 -3
View File
@@ -91,8 +91,12 @@ func _status_text() -> String:
if client == null: if client == null:
return "connecting..." return "connecting..."
var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON" var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON"
return "%s instance %d hp %d/%d %d fps" % [ var who := client.current_character()
where, client.instance_id, client.my_hp, SimConfig.PLAYER_MAX_HP, 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()] Engine.get_frames_per_second()]
@@ -110,8 +114,9 @@ func _draw_hud() -> void:
if client == null: if client == null:
return return
var origin := Vector2(MARGIN, MARGIN + 28.0) 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)) 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: if client.my_escaping:
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape, _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)) 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. ## Shown after the boss dies, so the victory lap has a visible clock on it.
func _draw_cleared_countdown() -> void: func _draw_cleared_countdown() -> void:
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE: if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
+18
View File
@@ -7,8 +7,12 @@ extends Node2D
@onready var hud: CanvasLayer = $HUD @onready var hud: CanvasLayer = $HUD
@onready var menu: CanvasLayer = $GameMenu @onready var menu: CanvasLayer = $GameMenu
@onready var sfx: Node = $Sfx @onready var sfx: Node = $Sfx
@onready var characters: CanvasLayer = $CharacterSelect
var _bound: ClientRuntime = null 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: func _ready() -> void:
@@ -17,6 +21,8 @@ func _ready() -> void:
menu.return_to_hub_requested.connect(_on_return_to_hub) menu.return_to_hub_requested.connect(_on_return_to_hub)
menu.disconnect_requested.connect(_on_disconnect) menu.disconnect_requested.connect(_on_disconnect)
hud.respawn_pressed.connect(_on_respawn_pressed) 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 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.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.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.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() _follow_camera()
menu.set_in_dungeon(_bound != null menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON) 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. ## 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: func _on_respawn_pressed() -> void:
if _bound != null: if _bound != null:
_bound.request_respawn = true _bound.request_respawn = true
+13 -5
View File
@@ -197,8 +197,10 @@ func _draw_boss() -> void:
func _draw_remote_player(p: Dictionary) -> void: func _draw_remote_player(p: Dictionary) -> void:
var flags := int(p["flags"]) var flags := int(p["flags"])
var alive := (flags & Protocol.F_ALIVE) != 0 var alive := (flags & Protocol.F_ALIVE) != 0
var col := COL_REMOTE if alive else COL_DEAD # Each character's own colour, which is the only thing telling party
_draw_ship(p["pos"], p["aim"], col, alive) # 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: if (flags & Protocol.F_SPAWN_GRACE) != 0:
_draw_grace_ring(p["pos"]) _draw_grace_ring(p["pos"])
if (flags & Protocol.F_ESCAPING) != 0: if (flags & Protocol.F_ESCAPING) != 0:
@@ -209,7 +211,9 @@ func _draw_local_player() -> void:
if not client.my_alive: if not client.my_alive:
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false) _draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
return 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: if client.my_spawn_grace:
_draw_grace_ring(client.predicted_pos) _draw_grace_ring(client.predicted_pos)
if client.my_escaping: 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 ## 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 ## can visibly clip the sprite without landing, which reads as more forgiving of
## latency than the reverse. See sim_config.gd. ## 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 strip := Art.PLAYER_RUN if moving else Art.PLAYER_IDLE
var src := Art.frame(strip, Art.anim_frame(_anim_time, 0)) 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. # Faces the way you aim, which is the whole point of a twin-stick.
var flip := absf(wrapf(aim, -PI, PI)) > PI * 0.5 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) Art.PLAYER_ANCHOR)
if alive: if alive:
var dir := Vector2.RIGHT.rotated(aim) var dir := Vector2.RIGHT.rotated(aim)
+150
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dxdd52gmk2ki4
+57
View File
@@ -161,3 +161,60 @@ func test_cleared_countdown_round_trips() -> void:
func test_countdown_defaults_to_not_applicable() -> void: func test_countdown_defaults_to_not_applicable() -> void:
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_eq(int(snap["cleared_countdown"]), Protocol.COUNTDOWN_NONE) 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")
+78
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://cllapwggk4ean
+137
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://mvqw6wolxjoe
+6
View File
@@ -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")
+12 -5
View File
@@ -32,7 +32,10 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
echo "smoke: server on port $PORT, logs in $OUT" 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" \ "$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
--store "$OUT/characters.json" \
> "$OUT/server.log" 2>&1 & > "$OUT/server.log" 2>&1 &
PIDS+=($!) PIDS+=($!)
@@ -46,7 +49,8 @@ fi
for n in 1 2; do for n in 1 2; do
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ "$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+=($!) PIDS+=($!)
sleep 0.4 sleep 0.4
done done
@@ -57,7 +61,7 @@ done
# SIGKILL below. If someone ever adds a "clean leave" message that bypasses the # SIGKILL below. If someone ever adds a "clean leave" message that bypasses the
# channel, this is what catches it. # channel, this is what catches it.
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ "$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 & > "$OUT/leavebot.log" 2>&1 &
PIDS+=($!) PIDS+=($!)
sleep 0.4 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 # 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. # forget the peer -- never delete it instantly on socket close.
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ "$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=$! DROP_PID=$!
for _ in $(seq 1 80); do for _ in $(seq 1 80); do
grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null && break grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null && break
@@ -106,8 +110,11 @@ refute() { # refute <label> <file> <pattern>
} }
echo "assertions:" echo "assertions:"
check "server accepted bot1" "$OUT/server.log" "joined as 'bot1'" check "bot1 authenticated" "$OUT/server.log" "authenticated as account 7001"
check "server accepted bot2" "$OUT/server.log" "joined as 'bot2'" check "bot2 authenticated" "$OUT/server.log" "authenticated as account 7002"
check "a character was created" "$OUT/server.log" "created 'bot1'"
check "and persisted to the store" "$OUT/characters.json" "bot1"
check "and are played" "$OUT/server.log" "playing 'bot1'"
check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance" check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
check "emergency escape completed" "$OUT/server.log" "escaped to lobby" check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON" check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"