Stage 2: accounts, characters, permadeath, levels and experience
ci / verify (push) Successful in 47s
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:
@@ -32,6 +32,8 @@ Everything after `--` goes to `GameOpts.parse()`:
|
||||
| `--autoquit N` | Quit after N physics ticks. |
|
||||
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
|
||||
| `--depth N` | Server-side: depth of new dungeons, which drives map size. |
|
||||
| `--account N` | Client-side: override the local account, so several clients can coexist on one machine. |
|
||||
| `--store PATH` | Server-side: character store location. Use a scratch path in tests. |
|
||||
| `--verbose` / `--quiet` | Log level. |
|
||||
|
||||
A change is done when `check.sh`, `test.sh` and — if it touched networking,
|
||||
@@ -70,6 +72,7 @@ and `tests/integration/test_replica_parity.gd` pin this down.
|
||||
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
|
||||
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. |
|
||||
| `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. |
|
||||
| `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. |
|
||||
| `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
|
||||
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
|
||||
| `src/instances/` | Lobby hub and dungeon runs. |
|
||||
@@ -133,6 +136,12 @@ ticks in milliseconds with no SceneTree.
|
||||
- **Bullet speed must stay under one tile per tick.** Wall collision samples
|
||||
position once per tick, so anything faster tunnels. Pinned by
|
||||
`test_bullet_speeds_stay_below_the_tunnelling_threshold`.
|
||||
- **Only `ServerRuntime` writes progression.** The simulation reads a player's
|
||||
level and max health; it never grants experience or retires a character. One
|
||||
writer means a level can never disagree with the experience that earned it.
|
||||
- **`LocalAuthProvider` is insecure on purpose.** Any client can claim any
|
||||
account. It exists to have the same shape as Steamworks (opaque ticket in,
|
||||
64-bit account id out) so swapping is one class. Do not ship it.
|
||||
- **No contact damage.** Every enemy threatens through bullets only; touching
|
||||
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
|
||||
an emitter.
|
||||
|
||||
@@ -126,3 +126,38 @@ dungeon should give a bit more than is needed for the first level-up.
|
||||
|
||||
**Permadeath.** Death marks a character inactive — never deleted, for archival
|
||||
and troubleshooting — and the player picks another character or creates one.
|
||||
|
||||
---
|
||||
|
||||
## Characters and progression *(this session)*
|
||||
|
||||
**Level 1 is base health; each level adds 10.** So level 15 is
|
||||
`PLAYER_MAX_HP + 14 * 10` = 240. Level is *derived* from lifetime experience
|
||||
rather than stored alongside it, so the two can never disagree — a hand-edited
|
||||
save cannot produce a level 12 character with a level 3's experience.
|
||||
|
||||
**The five-character cap counts LIVING characters only.** Retired ones stay in
|
||||
the store forever but free their slot. Counting the dead would lock a player out
|
||||
of their own account permanently after five deaths, which is not a punishment
|
||||
anyone signed up for.
|
||||
|
||||
**Death unbinds the character entirely.** There is deliberately no "return to
|
||||
the hub as the character who just died" — the run is over, so the peer is
|
||||
removed from the instance and left at the roster screen. The one exception is a
|
||||
linkdead player, which has nobody to show a roster to, so its body is left for
|
||||
the escape channel to resolve as before.
|
||||
|
||||
**Experience is shared across the party, undivided.** Everyone alive in the
|
||||
instance receives the full amount for a kill. Splitting it would make bringing a
|
||||
friend cost you progress, which is the opposite of what the hub roster exists to
|
||||
encourage.
|
||||
|
||||
**A level-up heals by the amount it added.** Gaining a level mid-fight should
|
||||
feel like relief, not like the bar you were watching got further from full.
|
||||
|
||||
**The character store refuses to start rather than starting empty.** A corrupt
|
||||
or unreadable save aborts the server. Loading empty would look like it worked
|
||||
and then overwrite every character on the first level-up.
|
||||
|
||||
**Account ids are written as decimal strings in JSON.** They are 64-bit and JSON
|
||||
numbers are doubles, which would silently round them.
|
||||
|
||||
+29
-4
@@ -71,7 +71,34 @@ play off server events. Enough to prove the pipeline, not a finished look.
|
||||
| **In-game credits screen** | **todo** | Not cosmetic: the SFX are CC BY 4.0 and attribution is a licence *requirement*. [CREDITS.md](../CREDITS.md) is not reachable by a player. |
|
||||
| Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). |
|
||||
|
||||
## Stage 2 — Characters, persistence, levels · *todo, next*
|
||||
## Stage 2 — Characters, persistence, levels · *done*
|
||||
|
||||
| Feature | State | Where |
|
||||
| --- | --- | --- |
|
||||
| Steam-shaped identity abstraction | done | [src/meta/auth_provider.gd](../src/meta/auth_provider.gd), [local_auth_provider.gd](../src/meta/local_auth_provider.gd) |
|
||||
| Character store, JSON, survives restart | done | [src/meta/character_store.gd](../src/meta/character_store.gd) |
|
||||
| Up to 5 living characters, random colour | done | `CharacterStore.MAX_ACTIVE`, `Character.create` |
|
||||
| Last-played auto-selected on login | done | `CharacterStore.last_played` |
|
||||
| Permadeath → retired, never deleted | done | `ServerRuntime._on_player_died` |
|
||||
| Roster screen: pick or create | done | [src/ui/character_select.gd](../src/ui/character_select.gd) |
|
||||
| Levels 1–15, +10 max HP each | done | [src/meta/progression.gd](../src/meta/progression.gd) |
|
||||
| XP from kills, bosses worth far more | done | `ServerRuntime._award_kill` |
|
||||
| Colour visible in world and on the HUD | done | snapshot carries it; `WorldView._draw_ship` tints |
|
||||
|
||||
Verified end to end by `tools/diag_progression.tscn`, which drives the real
|
||||
server through kill → xp → level → health and death → retire → roster. That
|
||||
path cannot be covered by the bot smoke test, because bots are poor shots.
|
||||
|
||||
### Still open in this area
|
||||
|
||||
- **The local identity provider is insecure by design.** Any client can claim
|
||||
any account id. Fine for a LAN; must be replaced before the game is reachable
|
||||
from the internet. Swapping in Steam is one `AuthProvider` subclass and no
|
||||
schema change.
|
||||
- `--account` and `--store` exist so several clients and test runs can coexist
|
||||
on one machine. A real provider makes `--account` unnecessary.
|
||||
|
||||
## Stage 3 — Inventory and loot · *todo, next*
|
||||
|
||||
Depends on nothing in Stage 1 except a place to stand. Blocked only on the
|
||||
identity layer, which is decided but unbuilt.
|
||||
@@ -92,7 +119,7 @@ Current behaviour to replace: `SimPlayer` has no identity beyond a peer id;
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — Upgrades · *todo*
|
||||
## Stage 4 — Upgrades · *todo*
|
||||
|
||||
| Feature | Notes |
|
||||
| --- | --- |
|
||||
@@ -110,8 +137,6 @@ bullets spawn per shot, so they belong in the same place.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Inventory and loot · *todo*
|
||||
|
||||
| Feature | Notes |
|
||||
| --- | --- |
|
||||
| Small always-on-screen inventory | Slot count unspecified. |
|
||||
|
||||
+62
-4
@@ -22,6 +22,10 @@ var server: ServerRuntime = null
|
||||
var client: ClientRuntime = null
|
||||
var last_error: String = ""
|
||||
|
||||
## How this build establishes identity. One line to swap for a Steam provider:
|
||||
## everything downstream deals only in the 64-bit account id it produces.
|
||||
var auth: AuthProvider = LocalAuthProvider.new()
|
||||
|
||||
|
||||
func _set_state(s: State) -> void:
|
||||
state = s
|
||||
@@ -116,7 +120,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
|
||||
|
||||
func _on_connected() -> void:
|
||||
_set_state(State.ONLINE)
|
||||
c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name)
|
||||
c_hello.rpc_id(1, Protocol.VERSION, auth.get_ticket())
|
||||
|
||||
|
||||
func _on_connect_failed() -> void:
|
||||
@@ -142,7 +146,7 @@ func start_local_client() -> void:
|
||||
client = ClientRuntime.new()
|
||||
client.name = "Client"
|
||||
add_child(client)
|
||||
server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name)
|
||||
server.on_hello(LOCAL_PEER, Protocol.VERSION, auth.get_ticket())
|
||||
|
||||
|
||||
func _is_local(peer_id: int) -> bool:
|
||||
@@ -194,6 +198,20 @@ func send_events(peer_id: int, data: PackedByteArray) -> void:
|
||||
s_events.rpc_id(peer_id, data)
|
||||
|
||||
|
||||
func send_characters(peer_id: int, data: PackedByteArray) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_characters(data)
|
||||
else:
|
||||
s_characters.rpc_id(peer_id, data)
|
||||
|
||||
|
||||
func send_select_result(peer_id: int, result: int, reason: String) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_select_result(result, reason)
|
||||
else:
|
||||
s_select_result.rpc_id(peer_id, result, reason)
|
||||
|
||||
|
||||
func send_roster(peer_id: int, data: PackedByteArray) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_roster(data)
|
||||
@@ -208,6 +226,20 @@ func send_reject(peer_id: int, reason: String) -> void:
|
||||
s_reject.rpc_id(peer_id, reason)
|
||||
|
||||
|
||||
func select_character(character_id: String) -> void:
|
||||
if server != null:
|
||||
server.on_select_character(LOCAL_PEER, character_id)
|
||||
elif state == State.ONLINE:
|
||||
c_select_character.rpc_id(1, character_id)
|
||||
|
||||
|
||||
func create_character(character_name: String) -> void:
|
||||
if server != null:
|
||||
server.on_create_character(LOCAL_PEER, character_name)
|
||||
elif state == State.ONLINE:
|
||||
c_create_character.rpc_id(1, character_name)
|
||||
|
||||
|
||||
func send_input(data: PackedByteArray) -> void:
|
||||
if server != null:
|
||||
server.on_input(LOCAL_PEER, data) # listen server: no transport at all
|
||||
@@ -218,10 +250,24 @@ func send_input(data: PackedByteArray) -> void:
|
||||
# --- Client -> server -------------------------------------------------------
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 1)
|
||||
func c_hello(version: int, display_name: String) -> void:
|
||||
func c_hello(version: int, ticket: PackedByteArray) -> void:
|
||||
if server == null:
|
||||
return
|
||||
server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
|
||||
server.on_hello(multiplayer.get_remote_sender_id(), version, ticket)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 1)
|
||||
func c_select_character(character_id: String) -> void:
|
||||
if server == null:
|
||||
return
|
||||
server.on_select_character(multiplayer.get_remote_sender_id(), character_id)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 1)
|
||||
func c_create_character(character_name: String) -> void:
|
||||
if server == null:
|
||||
return
|
||||
server.on_create_character(multiplayer.get_remote_sender_id(), character_name)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
|
||||
@@ -262,6 +308,18 @@ func s_reject(reason: String) -> void:
|
||||
_set_state(State.FAILED)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
func s_characters(data: PackedByteArray) -> void:
|
||||
if client != null:
|
||||
client.on_characters(data)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
func s_select_result(result: int, reason: String) -> void:
|
||||
if client != null:
|
||||
client.on_select_result(result, reason)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
func s_roster(data: PackedByteArray) -> void:
|
||||
if client != null:
|
||||
|
||||
@@ -30,6 +30,15 @@ static var boss_rush: bool = false
|
||||
## Dev switch: depth of newly opened dungeons, which drives map size. Depth
|
||||
## progression is a later-stage concern; this makes big maps testable now.
|
||||
static var dungeon_depth: int = 1
|
||||
## Override the local account id. Two clients on one machine would otherwise
|
||||
## read the same user:// id file, land on the same account, and fight over one
|
||||
## roster of characters -- which is exactly what the smoke test does. A real
|
||||
## identity provider makes this unnecessary.
|
||||
static var account_override: int = 0
|
||||
## Where the server keeps characters. Overridable so a test run cannot read or
|
||||
## write the characters someone is actually playing -- and so repeated runs
|
||||
## start from a known state instead of resuming each other's progress.
|
||||
static var store_path: String = CharacterStore.SAVE_PATH
|
||||
static var parsed: bool = false
|
||||
|
||||
|
||||
@@ -54,6 +63,14 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
|
||||
listen = true
|
||||
"--boss-rush":
|
||||
boss_rush = true
|
||||
"--account":
|
||||
i += 1
|
||||
if i < argv.size():
|
||||
account_override = int(argv[i])
|
||||
"--store":
|
||||
i += 1
|
||||
if i < argv.size():
|
||||
store_path = argv[i]
|
||||
"--depth":
|
||||
i += 1
|
||||
if i < argv.size():
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=7 format=3]
|
||||
[gd_scene load_steps=8 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
|
||||
@@ -6,6 +6,7 @@
|
||||
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
|
||||
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"]
|
||||
[ext_resource type="Script" path="res://src/view/sfx.gd" id="6"]
|
||||
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
@@ -25,3 +26,6 @@ script = ExtResource("5")
|
||||
|
||||
[node name="Sfx" type="Node" parent="."]
|
||||
script = ExtResource("6")
|
||||
|
||||
[node name="CharacterSelect" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("7")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://fteslbujpkuo
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://tuerwia65i3i
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://becirpnytb1b8
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://hxyq22vllwgt
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjtfbpej8ywwf
|
||||
@@ -15,6 +15,9 @@ signal local_hit(damage: int)
|
||||
signal shot_fired
|
||||
signal enemy_died
|
||||
signal boss_died
|
||||
## The account's character roster changed: created, selected, levelled or died.
|
||||
signal characters_changed
|
||||
signal select_failed(reason: String)
|
||||
|
||||
var my_peer: int = 0
|
||||
var instance_id: int = 0
|
||||
@@ -38,6 +41,9 @@ var _last_move := Vector2.ZERO
|
||||
|
||||
# Authoritative mirror of the local player.
|
||||
var my_hp: int = SimConfig.PLAYER_MAX_HP
|
||||
## Follows the character's level, so the HUD bar cannot be computed from a
|
||||
## constant.
|
||||
var my_max_hp: int = SimConfig.PLAYER_MAX_HP
|
||||
var my_alive: bool = true
|
||||
var my_escape: float = 0.0
|
||||
var my_escaping: bool = false
|
||||
@@ -59,6 +65,14 @@ var request_escape: bool = false
|
||||
## Who is online and where, for the hub's player list. Server-pushed.
|
||||
var roster: Array[Dictionary] = []
|
||||
|
||||
## This account's characters, and which one is being played. Server-pushed;
|
||||
## the client never invents an entry.
|
||||
var characters: Array[Dictionary] = []
|
||||
var selected_character: String = ""
|
||||
## True once the server has told us the roster, so the UI can tell "no
|
||||
## characters yet" from "not asked yet".
|
||||
var characters_known: bool = false
|
||||
|
||||
## Whole seconds until a cleared dungeon returns the party, or
|
||||
## Protocol.COUNTDOWN_NONE outside that state.
|
||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||
@@ -227,6 +241,49 @@ func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
|
||||
NetCodec.decode_map_chunks_into(world.map, data)
|
||||
|
||||
|
||||
func on_characters(data: PackedByteArray) -> void:
|
||||
var decoded := NetCodec.decode_characters(data)
|
||||
characters = decoded["characters"]
|
||||
selected_character = String(decoded["selected"])
|
||||
characters_known = true
|
||||
characters_changed.emit()
|
||||
hud_dirty.emit()
|
||||
_bot_pick_character()
|
||||
|
||||
|
||||
## A bot has no roster screen to click, so it makes the choice the screen would
|
||||
## offer: resume a living character, or create one. Without this the smoke test
|
||||
## would authenticate and then stand at a menu forever.
|
||||
func _bot_pick_character() -> void:
|
||||
if not GameOpts.bot_client or not selected_character.is_empty():
|
||||
return
|
||||
for c in characters:
|
||||
if c["active"]:
|
||||
Net.select_character(String(c["id"]))
|
||||
return
|
||||
Net.create_character(GameOpts.player_name)
|
||||
|
||||
|
||||
func on_select_result(result: int, reason: String) -> void:
|
||||
if result != Protocol.SelectResult.OK:
|
||||
GameLog.warn("client", "character selection refused: %s" % reason)
|
||||
select_failed.emit(reason)
|
||||
|
||||
|
||||
## The character currently being played, or an empty dictionary while none is.
|
||||
func current_character() -> Dictionary:
|
||||
for c in characters:
|
||||
if String(c["id"]) == selected_character:
|
||||
return c
|
||||
return {}
|
||||
|
||||
|
||||
## True when the player has no character in the world and must pick one -- at
|
||||
## first login, or after their last one died.
|
||||
func needs_character() -> bool:
|
||||
return characters_known and selected_character.is_empty()
|
||||
|
||||
|
||||
func on_roster(data: PackedByteArray) -> void:
|
||||
roster = NetCodec.decode_roster(data)
|
||||
hud_dirty.emit()
|
||||
@@ -301,6 +358,7 @@ func on_snapshot(data: PackedByteArray) -> void:
|
||||
## land where the client should actually be right now.
|
||||
func _reconcile(rec: Dictionary) -> void:
|
||||
my_hp = int(rec["hp"])
|
||||
my_max_hp = int(rec["max_hp"])
|
||||
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
|
||||
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
|
||||
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
|
||||
|
||||
@@ -45,6 +45,10 @@ static func encode_snapshot(world: SimWorld,
|
||||
b.put_float(p.pos.y)
|
||||
b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536))
|
||||
b.put_u16(clampi(p.hp, 0, 65535))
|
||||
# Sent rather than assumed: max health follows the character's level, so
|
||||
# a HUD bar computed from a constant would be wrong for anyone past
|
||||
# level 1, and wrong for every other player in the party.
|
||||
b.put_u16(clampi(p.max_hp, 1, 65535))
|
||||
var flags := 0
|
||||
if p.alive:
|
||||
flags |= Protocol.F_ALIVE
|
||||
@@ -61,6 +65,10 @@ static func encode_snapshot(world: SimWorld,
|
||||
b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255))
|
||||
# Echoed so the owning client knows how far to rewind when reconciling.
|
||||
b.put_u32(p.last_input_tick)
|
||||
# Static per character, so sending it every snapshot is slightly
|
||||
# wasteful -- but it is four bytes, and the alternative is a separate
|
||||
# message plus the join-ordering bug where someone arrives before it.
|
||||
b.put_u32(p.colour.to_rgba32())
|
||||
|
||||
var live_enemies: Array[SimEnemy] = []
|
||||
for e in world.enemies.values():
|
||||
@@ -111,10 +119,12 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"pos": Vector2(b.get_float(), b.get_float()),
|
||||
"aim": float(b.get_u16()) / 65536.0 * TAU,
|
||||
"hp": b.get_u16(),
|
||||
"max_hp": b.get_u16(),
|
||||
"flags": b.get_u8(),
|
||||
"escape": float(b.get_u8()) / 255.0,
|
||||
"respawn_wait": float(b.get_u8()) / 10.0,
|
||||
"last_input_tick": b.get_u32(),
|
||||
"colour": Color.hex(b.get_u32()),
|
||||
})
|
||||
|
||||
var ecount := b.get_u16()
|
||||
@@ -353,3 +363,50 @@ static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int:
|
||||
map.apply_chunk(id, b.get_data(n)[1])
|
||||
applied += 1
|
||||
return applied
|
||||
|
||||
|
||||
# --- Character roster -------------------------------------------------------
|
||||
# Sent once at login and after any change. Low frequency and carries strings,
|
||||
# like the online roster, so it is the same fixed-header-then-utf8 shape.
|
||||
|
||||
static func encode_characters(chars: Array[Character], selected: String) -> PackedByteArray:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.put_utf8_string(selected)
|
||||
b.put_u8(mini(chars.size(), 255))
|
||||
for c in chars:
|
||||
b.put_utf8_string(c.id)
|
||||
b.put_utf8_string(c.display_name)
|
||||
b.put_u8(clampi(c.level, 1, 255))
|
||||
b.put_u32(maxi(c.total_xp, 0))
|
||||
b.put_u8(clampi(roundi(c.xp_progress() * 255.0), 0, 255))
|
||||
b.put_u16(clampi(c.max_hp(), 1, 65535))
|
||||
b.put_u8(1 if c.active else 0)
|
||||
b.put_u32(c.colour.to_rgba32())
|
||||
return b.data_array
|
||||
|
||||
|
||||
## Returns { "selected": String, "characters": Array[Dictionary] }.
|
||||
static func decode_characters(data: PackedByteArray) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
if data.size() < 1:
|
||||
return {"selected": "", "characters": out}
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.data_array = data
|
||||
var selected := b.get_utf8_string()
|
||||
var count := b.get_u8()
|
||||
for _i in count:
|
||||
if b.get_available_bytes() <= 0:
|
||||
break
|
||||
out.append({
|
||||
"id": b.get_utf8_string(),
|
||||
"name": b.get_utf8_string(),
|
||||
"level": b.get_u8(),
|
||||
"xp": b.get_u32(),
|
||||
"progress": float(b.get_u8()) / 255.0,
|
||||
"max_hp": b.get_u16(),
|
||||
"active": b.get_u8() == 1,
|
||||
"colour": Color.hex(b.get_u32()),
|
||||
})
|
||||
return {"selected": selected, "characters": out}
|
||||
|
||||
+7
-1
@@ -10,7 +10,9 @@ extends RefCounted
|
||||
## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts
|
||||
## the wire value of every event after it -- a mismatched client would
|
||||
## mis-decode every hit and death, so the handshake has to reject it.
|
||||
const VERSION := 4
|
||||
## 5: handshake carries an auth ticket instead of a bare name; added character
|
||||
## list/select/create messages and per-player max health in the snapshot.
|
||||
const VERSION := 5
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
@@ -25,6 +27,10 @@ const CHANNEL_COUNT := 8
|
||||
|
||||
enum InstanceKind { LOBBY, DUNGEON }
|
||||
|
||||
## Why a character selection failed. Sent rather than a bare "no", so the UI can
|
||||
## say something useful instead of appearing broken.
|
||||
enum SelectResult { OK, NO_SUCH_CHARACTER, CHARACTER_IS_DEAD, LIMIT_REACHED, NOT_AUTHENTICATED }
|
||||
|
||||
## Player flags packed into the snapshot's per-player byte.
|
||||
const F_ALIVE := 1
|
||||
const F_ESCAPING := 4
|
||||
|
||||
+203
-8
@@ -11,6 +11,14 @@ extends Node
|
||||
var instances: Dictionary[int, Instance] = {}
|
||||
var peer_instance: Dictionary[int, int] = {}
|
||||
var peer_names: Dictionary[int, String] = {}
|
||||
## Authenticated account behind each peer. Set at handshake and never taken
|
||||
## from anything the client says afterwards.
|
||||
var peer_accounts: Dictionary[int, int] = {}
|
||||
## Which character each peer is currently playing.
|
||||
var peer_characters: Dictionary[int, String] = {}
|
||||
## Characters, levels and experience. Owned here: the simulation reads a
|
||||
## player's level, but only this layer ever writes progression.
|
||||
var store: CharacterStore = null
|
||||
## Map chunks each peer has been sent, per peer. Reset on every instance
|
||||
## transfer -- knowledge of one dungeon must not carry into the next.
|
||||
var peer_chunks: Dictionary[int, Dictionary] = {}
|
||||
@@ -21,6 +29,14 @@ var _snapshot_phase: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if store == null:
|
||||
store = CharacterStore.new(GameOpts.store_path)
|
||||
if not store.load_from_disk():
|
||||
# Refusing to start beats starting empty and saving over everyone's
|
||||
# characters on the first level-up.
|
||||
GameLog.error("server", "character store failed to load; refusing to start")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
lobby = Instance.make_lobby(_take_instance_id())
|
||||
instances[lobby.id] = lobby
|
||||
GameLog.info("server", "lobby instance %d up" % lobby.id)
|
||||
@@ -74,8 +90,18 @@ func _dispatch_events(inst: Instance) -> void:
|
||||
# inst.peers and would otherwise change the list mid-broadcast.
|
||||
var to_lobby: Array[int] = []
|
||||
var to_dungeon: Array[int] = []
|
||||
var died: Array[int] = []
|
||||
for ev in events:
|
||||
match int(ev["t"]):
|
||||
SimEvent.Type.ENEMY_DIED:
|
||||
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
|
||||
SimEvent.Type.PLAYER_DIED:
|
||||
# Deferred like the transfers below: the payload has not been
|
||||
# sent yet, and a player must still receive news of its own
|
||||
# death before it stops being a member of the instance.
|
||||
died.append(int(ev["peer"]))
|
||||
SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED:
|
||||
var peer := int(ev["peer"])
|
||||
if not to_lobby.has(peer):
|
||||
@@ -107,6 +133,8 @@ func _dispatch_events(inst: Instance) -> void:
|
||||
continue
|
||||
Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer))
|
||||
|
||||
for peer in died:
|
||||
_on_player_died(inst, peer)
|
||||
for peer in to_lobby:
|
||||
_send_to_lobby(peer)
|
||||
for peer in to_dungeon:
|
||||
@@ -145,6 +173,8 @@ func _forget_peer(peer_id: int) -> void:
|
||||
peer_instance.erase(peer_id)
|
||||
peer_names.erase(peer_id)
|
||||
peer_chunks.erase(peer_id)
|
||||
peer_accounts.erase(peer_id)
|
||||
peer_characters.erase(peer_id)
|
||||
_broadcast_roster()
|
||||
|
||||
|
||||
@@ -158,22 +188,98 @@ func _release_linkdead(peer_id: int, inst: Instance) -> void:
|
||||
_forget_peer(peer_id)
|
||||
|
||||
|
||||
func on_hello(peer_id: int, version: int, display_name: String) -> void:
|
||||
if peer_names.has(peer_id):
|
||||
## Handshake: validate the ticket into an account, then offer that account's
|
||||
## characters. A peer is NOT placed in the world here -- it has no character
|
||||
## yet, and a player without a character has nothing to control.
|
||||
func on_hello(peer_id: int, version: int, ticket: PackedByteArray) -> void:
|
||||
if peer_accounts.has(peer_id):
|
||||
return # a second hello from the same peer is either a bug or an attack
|
||||
if version != Protocol.VERSION:
|
||||
GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION])
|
||||
Net.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
|
||||
Net.kick(peer_id)
|
||||
return
|
||||
# Never trust a client-supplied string for anything but display.
|
||||
var clean := display_name.strip_edges().substr(0, 24)
|
||||
if clean.is_empty():
|
||||
clean = "player%d" % peer_id
|
||||
peer_names[peer_id] = clean
|
||||
var account := Net.auth.validate(ticket)
|
||||
if account == AuthProvider.NO_ACCOUNT:
|
||||
GameLog.warn("server", "peer %d failed authentication" % peer_id)
|
||||
Net.send_reject(peer_id, "authentication failed")
|
||||
Net.kick(peer_id)
|
||||
return
|
||||
peer_accounts[peer_id] = account
|
||||
Net.send_welcome(peer_id)
|
||||
GameLog.info("server", "peer %d authenticated as account %d (%s)"
|
||||
% [peer_id, account, Net.auth.provider_name()])
|
||||
|
||||
# Auto-select the last character played, so a returning player lands in the
|
||||
# hub rather than at a menu they have already answered.
|
||||
var resume := store.last_played(account)
|
||||
if resume != null:
|
||||
_enter_world_as(peer_id, resume)
|
||||
_send_characters(peer_id)
|
||||
|
||||
|
||||
func _send_characters(peer_id: int) -> void:
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
if account == AuthProvider.NO_ACCOUNT:
|
||||
return
|
||||
Net.send_characters(peer_id, NetCodec.encode_characters(
|
||||
store.characters_for(account), peer_characters.get(peer_id, "")))
|
||||
|
||||
|
||||
func on_select_character(peer_id: int, character_id: String) -> void:
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
if account == AuthProvider.NO_ACCOUNT:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in")
|
||||
return
|
||||
# Looked up against THIS account's characters, so a client cannot select
|
||||
# somebody else's by guessing an id.
|
||||
var c := store.get_character(account, character_id)
|
||||
if c == null:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.NO_SUCH_CHARACTER, "no such character")
|
||||
return
|
||||
if not c.active:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.CHARACTER_IS_DEAD,
|
||||
"%s is dead" % c.display_name)
|
||||
return
|
||||
_enter_world_as(peer_id, c)
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
|
||||
_send_characters(peer_id)
|
||||
|
||||
|
||||
func on_create_character(peer_id: int, character_name: String) -> void:
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
if account == AuthProvider.NO_ACCOUNT:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in")
|
||||
return
|
||||
var c := store.create_character(account, character_name)
|
||||
if c == null:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.LIMIT_REACHED,
|
||||
"%d living characters is the limit" % CharacterStore.MAX_ACTIVE)
|
||||
return
|
||||
GameLog.info("server", "account %d created '%s'" % [account, c.display_name])
|
||||
_enter_world_as(peer_id, c)
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
|
||||
_send_characters(peer_id)
|
||||
|
||||
|
||||
## Put a peer into the hub playing [param c], switching characters if it was
|
||||
## already in the world.
|
||||
func _enter_world_as(peer_id: int, c: Character) -> void:
|
||||
var account: int = peer_accounts[peer_id]
|
||||
var previous := instance_of(peer_id)
|
||||
if previous != null:
|
||||
previous.remove_peer(peer_id)
|
||||
peer_characters[peer_id] = c.id
|
||||
peer_names[peer_id] = c.display_name
|
||||
store.set_last_played(account, c.id)
|
||||
_place(peer_id, lobby)
|
||||
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
|
||||
var p: SimPlayer = lobby.world.players.get(peer_id)
|
||||
if p != null:
|
||||
p.adopt(c)
|
||||
p.reset_for_instance(lobby.world.spawn_point, 0)
|
||||
p.adopt(c)
|
||||
GameLog.info("server", "peer %d playing '%s' (level %d)"
|
||||
% [peer_id, c.display_name, c.level])
|
||||
_broadcast_roster()
|
||||
|
||||
|
||||
@@ -228,6 +334,23 @@ func _send_to_lobby(peer_id: int) -> void:
|
||||
if p != null and p.linkdead:
|
||||
_release_linkdead(peer_id, from)
|
||||
return
|
||||
# The character that just died is retired, so returning "as them" is not an
|
||||
# option. Fall back to whatever is left, and leave the player at the
|
||||
# character screen if nothing is.
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
if account != AuthProvider.NO_ACCOUNT:
|
||||
var current := store.get_character(account, peer_characters.get(peer_id, ""))
|
||||
if current == null or not current.active:
|
||||
var replacement := store.last_played(account)
|
||||
if replacement != null:
|
||||
_enter_world_as(peer_id, replacement)
|
||||
else:
|
||||
if from != null:
|
||||
from.remove_peer(peer_id)
|
||||
peer_instance.erase(peer_id)
|
||||
peer_characters.erase(peer_id)
|
||||
_send_characters(peer_id)
|
||||
return
|
||||
|
||||
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
|
||||
_transfer(peer_id, lobby)
|
||||
@@ -283,6 +406,78 @@ func _stream_map(peer_id: int, inst: Instance) -> void:
|
||||
NetCodec.encode_map_chunks(inst.world.map, batch))
|
||||
|
||||
|
||||
## Experience is shared by everyone alive in the instance, undivided. Splitting
|
||||
## it would make bringing a friend cost you progress, which is the opposite of
|
||||
## what a co-op game wants; the hub roster exists to help people group up.
|
||||
func _award_kill(inst: Instance, amount: int) -> void:
|
||||
if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON:
|
||||
return
|
||||
for peer in inst.peers:
|
||||
var p: SimPlayer = inst.world.players.get(peer)
|
||||
if p == null or not p.alive:
|
||||
continue
|
||||
_grant_xp(peer, amount)
|
||||
|
||||
|
||||
func _grant_xp(peer_id: int, amount: int) -> void:
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
var character_id: String = peer_characters.get(peer_id, "")
|
||||
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
|
||||
return
|
||||
var levels := store.grant_xp(account, character_id, amount)
|
||||
if levels <= 0:
|
||||
return
|
||||
# A level raises max health immediately, and heals by the amount gained --
|
||||
# a level-up mid-fight should feel like relief, not like a bar that grew
|
||||
# further away from full.
|
||||
var c := store.get_character(account, character_id)
|
||||
var inst := instance_of(peer_id)
|
||||
if inst != null:
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p != null:
|
||||
var before := p.max_hp
|
||||
p.level = c.level
|
||||
p.max_hp = c.max_hp()
|
||||
p.hp = mini(p.hp + (p.max_hp - before), p.max_hp)
|
||||
GameLog.info("server", "peer %d reached level %d" % [peer_id, c.level])
|
||||
_send_characters(peer_id)
|
||||
|
||||
|
||||
## Death is permanent. The character is retired -- kept for archival, never
|
||||
## deleted -- and the player is taken out of the world entirely.
|
||||
##
|
||||
## There is deliberately no "return to the hub as the character who just died":
|
||||
## the run is over, so the peer is unbound and left at the roster screen to pick
|
||||
## another or make one. A linkdead player is the exception -- it has nobody to
|
||||
## show a roster to, so its body is left for the escape channel to resolve.
|
||||
func _on_player_died(inst: Instance, peer_id: int) -> void:
|
||||
if inst.kind != Protocol.InstanceKind.DUNGEON:
|
||||
return
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
var character_id: String = peer_characters.get(peer_id, "")
|
||||
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
|
||||
return
|
||||
var c := store.get_character(account, character_id)
|
||||
if c == null or not c.active:
|
||||
return
|
||||
store.retire_character(account, character_id)
|
||||
GameLog.info("server", "peer %d lost '%s' at level %d"
|
||||
% [peer_id, c.display_name, c.level])
|
||||
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p != null and p.linkdead:
|
||||
_send_characters(peer_id)
|
||||
_broadcast_roster()
|
||||
return
|
||||
|
||||
inst.remove_peer(peer_id)
|
||||
peer_instance.erase(peer_id)
|
||||
peer_characters.erase(peer_id)
|
||||
peer_chunks.erase(peer_id)
|
||||
_send_characters(peer_id)
|
||||
_broadcast_roster()
|
||||
|
||||
|
||||
# --- Roster -----------------------------------------------------------------
|
||||
|
||||
## Tell everyone who is online and where they are, so the hub can show that a
|
||||
|
||||
+21
-1
@@ -5,9 +5,18 @@ extends RefCounted
|
||||
|
||||
var peer_id: int = 0
|
||||
var display_name: String = "player"
|
||||
## Which character this player is. Set by the instance layer from the account's
|
||||
## store; the simulation only reads max_hp and colour from it, and never writes
|
||||
## progression -- experience is banked by ServerRuntime, which owns the store.
|
||||
var character_id: String = ""
|
||||
var level: int = Progression.START_LEVEL
|
||||
var colour := Color.WHITE
|
||||
var pos := Vector2.ZERO
|
||||
var aim: float = 0.0
|
||||
var hp: int = SimConfig.PLAYER_MAX_HP
|
||||
## Cached from the character's level, so hit resolution never reaches outside
|
||||
## the simulation to work out how much health someone has.
|
||||
var max_hp: int = SimConfig.PLAYER_MAX_HP
|
||||
var alive: bool = true
|
||||
var fire_cooldown: int = 0
|
||||
## Ticks of arrival protection left: invulnerable, and unable to shoot.
|
||||
@@ -54,7 +63,8 @@ func can_fire() -> bool:
|
||||
## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
|
||||
func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
|
||||
pos = spawn
|
||||
hp = SimConfig.PLAYER_MAX_HP
|
||||
max_hp = Progression.max_hp_for_level(level)
|
||||
hp = max_hp
|
||||
alive = true
|
||||
spawn_grace = grace
|
||||
respawn_lockout = 0
|
||||
@@ -66,3 +76,13 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
|
||||
## True once the lockout has run out and the hub is available again.
|
||||
func can_request_respawn() -> bool:
|
||||
return not alive and respawn_lockout <= 0
|
||||
|
||||
|
||||
## Adopt a character's stats. Called when a player picks or switches character.
|
||||
func adopt(c: Character) -> void:
|
||||
character_id = c.id
|
||||
display_name = c.display_name
|
||||
level = c.level
|
||||
colour = c.colour
|
||||
max_hp = c.max_hp()
|
||||
hp = mini(hp, max_hp)
|
||||
|
||||
@@ -436,7 +436,9 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
||||
if e.hp <= 0:
|
||||
e.alive = false
|
||||
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id})
|
||||
# The def id rides along so the instance layer can score it without
|
||||
# looking up an actor that is about to stop existing.
|
||||
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
|
||||
|
||||
|
||||
func _damage_boss(amount: int) -> void:
|
||||
@@ -447,7 +449,7 @@ func _damage_boss(amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
|
||||
if boss.hp <= 0:
|
||||
boss.alive = false
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED})
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
|
||||
|
||||
|
||||
## Bullets that died against geometry. A client is only streamed the map near
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://w2dehyfmfm0i
|
||||
+21
-3
@@ -91,8 +91,12 @@ func _status_text() -> String:
|
||||
if client == null:
|
||||
return "connecting..."
|
||||
var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON"
|
||||
return "%s instance %d hp %d/%d %d fps" % [
|
||||
where, client.instance_id, client.my_hp, SimConfig.PLAYER_MAX_HP,
|
||||
var who := client.current_character()
|
||||
var name_part := ""
|
||||
if not who.is_empty():
|
||||
name_part = "%s lv %d " % [who["name"], int(who["level"])]
|
||||
return "%s%s instance %d hp %d/%d %d fps" % [
|
||||
name_part, where, client.instance_id, client.my_hp, client.my_max_hp,
|
||||
Engine.get_frames_per_second()]
|
||||
|
||||
|
||||
@@ -110,8 +114,9 @@ func _draw_hud() -> void:
|
||||
if client == null:
|
||||
return
|
||||
var origin := Vector2(MARGIN, MARGIN + 28.0)
|
||||
_bar(origin, float(client.my_hp) / float(SimConfig.PLAYER_MAX_HP),
|
||||
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
|
||||
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
|
||||
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 3.0))
|
||||
|
||||
if client.my_escaping:
|
||||
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape,
|
||||
@@ -144,6 +149,19 @@ func _draw_hud() -> void:
|
||||
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
|
||||
|
||||
|
||||
## A thin bar under health: progress toward the next level, and the level
|
||||
## itself. Drawn from the server's numbers, never recomputed locally.
|
||||
func _draw_xp_bar(at: Vector2) -> void:
|
||||
var who := client.current_character()
|
||||
if who.is_empty():
|
||||
return
|
||||
var level := int(who["level"])
|
||||
var progress: float = who["progress"] if level < Progression.MAX_LEVEL else 1.0
|
||||
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 4.0)), Color(0.1, 0.12, 0.18))
|
||||
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 4.0)),
|
||||
Color(0.6, 0.55, 1.0) if level < Progression.MAX_LEVEL else Color(1.0, 0.85, 0.4))
|
||||
|
||||
|
||||
## Shown after the boss dies, so the victory lap has a visible clock on it.
|
||||
func _draw_cleared_countdown() -> void:
|
||||
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
|
||||
|
||||
@@ -7,8 +7,12 @@ extends Node2D
|
||||
@onready var hud: CanvasLayer = $HUD
|
||||
@onready var menu: CanvasLayer = $GameMenu
|
||||
@onready var sfx: Node = $Sfx
|
||||
@onready var characters: CanvasLayer = $CharacterSelect
|
||||
|
||||
var _bound: ClientRuntime = null
|
||||
## Opened deliberately from the menu, as opposed to forced open by having no
|
||||
## character to play.
|
||||
var _roster_open: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -17,6 +21,8 @@ func _ready() -> void:
|
||||
menu.return_to_hub_requested.connect(_on_return_to_hub)
|
||||
menu.disconnect_requested.connect(_on_disconnect)
|
||||
hud.respawn_pressed.connect(_on_respawn_pressed)
|
||||
characters.select_requested.connect(func(id: String) -> void: Net.select_character(id))
|
||||
characters.create_requested.connect(func(n: String) -> void: Net.create_character(n))
|
||||
|
||||
|
||||
var _screen_centre := Vector2.ZERO
|
||||
@@ -48,6 +54,13 @@ func _process(_delta: float) -> void:
|
||||
_bound.shot_fired.connect(func() -> void: sfx.play(Art.SFX_SHOOT, -14.0))
|
||||
_bound.enemy_died.connect(func() -> void: sfx.play(Art.SFX_ENEMY_DEATH, -8.0))
|
||||
_bound.boss_died.connect(func() -> void: sfx.play(Art.SFX_BOSS_DEATH, -2.0))
|
||||
_bound.characters_changed.connect(_refresh_characters)
|
||||
_bound.select_failed.connect(func(why: String) -> void: characters.set_status(why))
|
||||
_refresh_characters()
|
||||
# The roster screen is shown exactly when there is nothing to play: first
|
||||
# login, or after the last living character died.
|
||||
if _bound != null:
|
||||
characters.visible = _bound.needs_character() or _roster_open
|
||||
_follow_camera()
|
||||
menu.set_in_dungeon(_bound != null
|
||||
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
|
||||
@@ -66,6 +79,11 @@ func _on_disconnect() -> void:
|
||||
|
||||
|
||||
## Sets the intent bit; the server decides whether the lockout has expired.
|
||||
func _refresh_characters() -> void:
|
||||
if _bound != null:
|
||||
characters.refresh(_bound.characters, _bound.selected_character)
|
||||
|
||||
|
||||
func _on_respawn_pressed() -> void:
|
||||
if _bound != null:
|
||||
_bound.request_respawn = true
|
||||
|
||||
+13
-5
@@ -197,8 +197,10 @@ func _draw_boss() -> void:
|
||||
func _draw_remote_player(p: Dictionary) -> void:
|
||||
var flags := int(p["flags"])
|
||||
var alive := (flags & Protocol.F_ALIVE) != 0
|
||||
var col := COL_REMOTE if alive else COL_DEAD
|
||||
_draw_ship(p["pos"], p["aim"], col, alive)
|
||||
# Each character's own colour, which is the only thing telling party
|
||||
# members apart until there is a cosmetic system.
|
||||
var col: Color = p["colour"] if alive else COL_DEAD
|
||||
_draw_ship(p["pos"], p["aim"], col, alive, false, Art.PLAYER_ANCHOR)
|
||||
if (flags & Protocol.F_SPAWN_GRACE) != 0:
|
||||
_draw_grace_ring(p["pos"])
|
||||
if (flags & Protocol.F_ESCAPING) != 0:
|
||||
@@ -209,7 +211,9 @@ func _draw_local_player() -> void:
|
||||
if not client.my_alive:
|
||||
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
|
||||
return
|
||||
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true, client.is_moving())
|
||||
var mine := client.current_character()
|
||||
var my_colour: Color = mine["colour"] if not mine.is_empty() else COL_LOCAL
|
||||
_draw_ship(client.predicted_pos, client.aim, my_colour, true, client.is_moving())
|
||||
if client.my_spawn_grace:
|
||||
_draw_grace_ring(client.predicted_pos)
|
||||
if client.my_escaping:
|
||||
@@ -228,12 +232,16 @@ func _draw_grace_ring(pos: Vector2) -> void:
|
||||
## is deliberately larger than PLAYER_RADIUS, the hitbox used for hits. A bullet
|
||||
## can visibly clip the sprite without landing, which reads as more forgiving of
|
||||
## latency than the reverse. See sim_config.gd.
|
||||
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool = false) -> void:
|
||||
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool,
|
||||
moving: bool = false, _unused: Vector2 = Vector2.ZERO) -> void:
|
||||
var strip := Art.PLAYER_RUN if moving else Art.PLAYER_IDLE
|
||||
var src := Art.frame(strip, Art.anim_frame(_anim_time, 0))
|
||||
# Faces the way you aim, which is the whole point of a twin-stick.
|
||||
var flip := absf(wrapf(aim, -PI, PI)) > PI * 0.5
|
||||
_draw_sprite(Art.TILESET, src, pos, col if not alive else Color.WHITE, flip,
|
||||
# Tinted rather than replaced: modulate keeps the sprite's shading, so a
|
||||
# character reads as coloured armour rather than a flat silhouette.
|
||||
var tint := col if alive else COL_DEAD
|
||||
_draw_sprite(Art.TILESET, src, pos, tint.lerp(Color.WHITE, 0.35), flip,
|
||||
Art.PLAYER_ANCHOR)
|
||||
if alive:
|
||||
var dir := Vector2.RIGHT.rotated(aim)
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxdd52gmk2ki4
|
||||
@@ -161,3 +161,60 @@ func test_cleared_countdown_round_trips() -> void:
|
||||
func test_countdown_defaults_to_not_applicable() -> void:
|
||||
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
||||
assert_eq(int(snap["cleared_countdown"]), Protocol.COUNTDOWN_NONE)
|
||||
|
||||
|
||||
# --- Characters -------------------------------------------------------------
|
||||
|
||||
func test_character_roster_round_trips() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 5
|
||||
var a := Character.create("Ada", rng)
|
||||
a.grant_xp(Progression.total_xp_for_level(4))
|
||||
var b := Character.create("Departed", rng)
|
||||
b.retire()
|
||||
var out := NetCodec.decode_characters(
|
||||
NetCodec.encode_characters([a, b] as Array[Character], a.id))
|
||||
assert_eq(String(out["selected"]), a.id)
|
||||
var chars: Array = out["characters"]
|
||||
assert_eq(chars.size(), 2)
|
||||
assert_eq(String(chars[0]["name"]), "Ada")
|
||||
assert_eq(int(chars[0]["level"]), a.level)
|
||||
assert_eq(int(chars[0]["max_hp"]), a.max_hp(),
|
||||
"the roster shows each character's own health ceiling")
|
||||
assert_true(chars[0]["active"])
|
||||
assert_false(chars[1]["active"], "a dead character is listed, not hidden")
|
||||
|
||||
|
||||
## Colour is a character's only identity until cosmetics exist, so it has to
|
||||
## survive the wire exactly rather than approximately.
|
||||
func test_character_colour_survives_the_wire() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 11
|
||||
var c := Character.create("Hue", rng)
|
||||
var out := NetCodec.decode_characters(
|
||||
NetCodec.encode_characters([c] as Array[Character], ""))
|
||||
var got: Color = out["characters"][0]["colour"]
|
||||
assert_eq(got.to_rgba32(), c.colour.to_rgba32())
|
||||
|
||||
|
||||
func test_an_empty_character_list_is_safe() -> void:
|
||||
var out := NetCodec.decode_characters(
|
||||
NetCodec.encode_characters([] as Array[Character], ""))
|
||||
assert_eq((out["characters"] as Array).size(), 0)
|
||||
assert_eq(NetCodec.decode_characters(PackedByteArray())["characters"].size(), 0)
|
||||
|
||||
|
||||
## Max health is per-character now, so a snapshot that assumed a constant would
|
||||
## draw every levelled player's bar wrong.
|
||||
func test_the_snapshot_carries_each_players_own_max_health() -> void:
|
||||
var p: SimPlayer = world.players[42]
|
||||
p.level = 5
|
||||
p.max_hp = Progression.max_hp_for_level(5)
|
||||
p.hp = 42
|
||||
p.colour = Color(0.2, 0.6, 0.9)
|
||||
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
||||
var rec: Dictionary = snap["players"][0]
|
||||
assert_eq(int(rec["hp"]), 42)
|
||||
assert_eq(int(rec["max_hp"]), Progression.max_hp_for_level(5))
|
||||
assert_almost_eq((rec["colour"] as Color).b, 0.9, 0.02,
|
||||
"party members are told apart by colour, so it rides the snapshot")
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cllapwggk4ean
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://mvqw6wolxjoe
|
||||
@@ -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
@@ -32,7 +32,10 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "smoke: server on port $PORT, logs in $OUT"
|
||||
# A scratch character store: without it a rerun resumes the characters the
|
||||
# previous run created, and "a character was created" stops being true.
|
||||
"$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
|
||||
--store "$OUT/characters.json" \
|
||||
> "$OUT/server.log" 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
@@ -46,7 +49,8 @@ fi
|
||||
|
||||
for n in 1 2; do
|
||||
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
||||
--name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
|
||||
--name "bot$n" --account "$((7000 + n))" \
|
||||
--autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
|
||||
PIDS+=($!)
|
||||
sleep 0.4
|
||||
done
|
||||
@@ -57,7 +61,7 @@ done
|
||||
# SIGKILL below. If someone ever adds a "clean leave" message that bypasses the
|
||||
# channel, this is what catches it.
|
||||
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
||||
--name "leavebot" --leave-after 120 --autoquit "$CLIENT_TICKS" \
|
||||
--name "leavebot" --account 7101 --leave-after 120 --autoquit "$CLIENT_TICKS" \
|
||||
> "$OUT/leavebot.log" 2>&1 &
|
||||
PIDS+=($!)
|
||||
sleep 0.4
|
||||
@@ -67,7 +71,7 @@ sleep 0.4
|
||||
# channel it out over the same one second the escape button costs, and only then
|
||||
# forget the peer -- never delete it instantly on socket close.
|
||||
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
||||
--name "dropbot" --autoquit "$CLIENT_TICKS" > "$OUT/dropbot.log" 2>&1 &
|
||||
--name "dropbot" --account 7102 --autoquit "$CLIENT_TICKS" > "$OUT/dropbot.log" 2>&1 &
|
||||
DROP_PID=$!
|
||||
for _ in $(seq 1 80); do
|
||||
grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null && break
|
||||
@@ -106,8 +110,11 @@ refute() { # refute <label> <file> <pattern>
|
||||
}
|
||||
|
||||
echo "assertions:"
|
||||
check "server accepted bot1" "$OUT/server.log" "joined as 'bot1'"
|
||||
check "server accepted bot2" "$OUT/server.log" "joined as 'bot2'"
|
||||
check "bot1 authenticated" "$OUT/server.log" "authenticated as account 7001"
|
||||
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 "emergency escape completed" "$OUT/server.log" "escaped to lobby"
|
||||
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
|
||||
|
||||
Reference in New Issue
Block a user