b351bc2d55
ci / verify (push) Successful in 48s
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.
The four ambiguities in the brief were settled with the user first, since
each changes what gets written:
damage base x (1 + sum additive) x product multiplicative. The flat
+5% every upgrade carries, spread's -10%, doubleshot's -50%
and glass cannon's +100% pool; sniper multiplies on top, so
two snipers is 4x and not +200%.
glass half the LEVELLED maximum, multiplying if taken twice, so the
price does not fade to a rounding error by level 15.
poison independent stacks, not a refresh.
split +/-45 degrees from the original heading.
Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.
Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.
Two invariants this collided with, both now pinned:
- bullet speed gained a ceiling. Wall collision samples once per tick, so
anything over a tile per tick tunnels; two snipers asked for 2480 u/s
against a 1920 threshold, and a tunnelling bullet looks like a bullet.
- BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
now the longest-travelling bullet in the game. test_interest measured
the worst case from static content, which upgrades quietly invalidated.
Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.
tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.
Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.
check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
594 lines
19 KiB
GDScript
594 lines
19 KiB
GDScript
class_name NetCodec
|
|
extends RefCounted
|
|
## Binary encoders for the two server -> client streams.
|
|
##
|
|
## Snapshots are lossy and unreliable: only what is needed to draw and predict.
|
|
## Events are exact and reliable: things a client can never re-derive, above all
|
|
## bullet spawns and the despawns caused by a hit.
|
|
|
|
# --- Snapshot ---------------------------------------------------------------
|
|
|
|
## [param cleared_countdown] is whole seconds until a cleared dungeon returns
|
|
## its party, or Protocol.COUNTDOWN_NONE when that does not apply.
|
|
## [param for_peer] scopes the snapshot to what that player may know: actors
|
|
## beyond SimConfig.ACTOR_INTEREST_RADIUS are omitted entirely, so fog is not
|
|
## the only thing hiding them. Pass 0 to encode the whole world (tests, and the
|
|
## safe fallback if the observer cannot be found).
|
|
static func encode_snapshot(world: SimWorld,
|
|
cleared_countdown: int = Protocol.COUNTDOWN_NONE,
|
|
for_peer: int = 0) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u32(world.tick)
|
|
b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE))
|
|
|
|
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
|
|
var eye := Vector2.ZERO
|
|
var cull_sq := 0.0
|
|
if observer != null:
|
|
eye = observer.pos
|
|
cull_sq = SimConfig.ACTOR_INTEREST_RADIUS * SimConfig.ACTOR_INTEREST_RADIUS
|
|
|
|
var visible_players: Array[SimPlayer] = []
|
|
for p in world.players.values():
|
|
# Always include the observer's own player, whatever the distance
|
|
# arithmetic says: the client reconciles its prediction against this
|
|
# record, and dropping it would break its own movement.
|
|
if observer == null or p.peer_id == for_peer \
|
|
or eye.distance_squared_to(p.pos) <= cull_sq:
|
|
visible_players.append(p)
|
|
|
|
b.put_u8(mini(visible_players.size(), 255))
|
|
for p in visible_players:
|
|
b.put_u32(p.peer_id)
|
|
b.put_float(p.pos.x)
|
|
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
|
|
if p.escape_ticks > 0:
|
|
flags |= Protocol.F_ESCAPING
|
|
if p.spawn_grace > 0:
|
|
flags |= Protocol.F_SPAWN_GRACE
|
|
if p.linkdead:
|
|
flags |= Protocol.F_LINKDEAD
|
|
b.put_u8(flags)
|
|
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
|
|
# Whole ticks are more than the HUD needs; a byte of tenths of a second
|
|
# covers the lockout with room to spare.
|
|
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())
|
|
# Experience rides the snapshot rather than waiting for a roster
|
|
# message: the bar has to move on every kill, and the roster is only
|
|
# re-sent when the set of characters actually changes.
|
|
b.put_u32(maxi(p.total_xp, 0))
|
|
|
|
var live_enemies: Array[SimEnemy] = []
|
|
for e in world.enemies.values():
|
|
if not e.alive:
|
|
continue
|
|
if observer != null and eye.distance_squared_to(e.pos) > cull_sq:
|
|
continue
|
|
live_enemies.append(e)
|
|
b.put_u16(mini(live_enemies.size(), 65535))
|
|
for e in live_enemies:
|
|
b.put_u32(e.id)
|
|
b.put_float(e.pos.x)
|
|
b.put_float(e.pos.y)
|
|
b.put_u16(clampi(e.hp, 0, 65535))
|
|
# Radius and visual travel with the snapshot so a client that joins
|
|
# mid-fight can draw an enemy without any extra handshake.
|
|
b.put_u8(clampi(roundi(e.def.radius * 2.0), 0, 255))
|
|
b.put_u8(clampi(e.def.visual, 0, 255))
|
|
|
|
var has_boss := world.boss != null and world.boss.alive
|
|
if has_boss and observer != null \
|
|
and eye.distance_squared_to(world.boss.pos) > cull_sq:
|
|
has_boss = false
|
|
b.put_u8(1 if has_boss else 0)
|
|
if has_boss:
|
|
b.put_u32(world.boss.id)
|
|
b.put_float(world.boss.pos.x)
|
|
b.put_float(world.boss.pos.y)
|
|
b.put_u32(maxi(world.boss.hp, 0))
|
|
b.put_u8(clampi(world.boss.phase_index, 0, 255))
|
|
|
|
# Only the observer's own bag. Nobody needs to see what a party member is
|
|
# carrying, and not sending it means there is nothing to leak.
|
|
for i in SimConfig.INVENTORY_SLOTS:
|
|
var carried := Items.NONE
|
|
if observer != null and i < observer.inventory.size():
|
|
carried = observer.inventory[i]
|
|
b.put_u8(Items.index_of(carried))
|
|
|
|
# Ground loot. Player-instanced items are filtered here rather than hidden
|
|
# in the client: a peer is never told that another player's copy exists, so
|
|
# a modified client has nothing to reveal.
|
|
var visible_loot: Array[SimLoot] = []
|
|
for l in world.loot.values():
|
|
if observer != null:
|
|
if not l.visible_to(for_peer):
|
|
continue
|
|
if eye.distance_squared_to(l.pos) > cull_sq:
|
|
continue
|
|
visible_loot.append(l)
|
|
b.put_u16(mini(visible_loot.size(), 65535))
|
|
for l in visible_loot:
|
|
b.put_u32(l.id)
|
|
b.put_float(l.pos.x)
|
|
b.put_float(l.pos.y)
|
|
b.put_u8(Items.index_of(l.item))
|
|
return b.data_array
|
|
|
|
|
|
static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
var snap := {
|
|
"tick": b.get_u32(),
|
|
"cleared_countdown": b.get_u8(),
|
|
"players": [], "enemies": [], "boss": null,
|
|
"loot": [], "inventory": [],
|
|
}
|
|
|
|
var pcount := b.get_u8()
|
|
for _i in pcount:
|
|
snap["players"].append({
|
|
"peer": b.get_u32(),
|
|
"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()),
|
|
"total_xp": b.get_u32(),
|
|
})
|
|
|
|
var ecount := b.get_u16()
|
|
for _i in ecount:
|
|
snap["enemies"].append({
|
|
"id": b.get_u32(),
|
|
"pos": Vector2(b.get_float(), b.get_float()),
|
|
"hp": b.get_u16(),
|
|
"radius": float(b.get_u8()) * 0.5,
|
|
"visual": b.get_u8(),
|
|
})
|
|
|
|
if b.get_u8() == 1:
|
|
snap["boss"] = {
|
|
"id": b.get_u32(),
|
|
"pos": Vector2(b.get_float(), b.get_float()),
|
|
"hp": b.get_u32(),
|
|
"phase": b.get_u8(),
|
|
}
|
|
|
|
var inventory: Array[int] = []
|
|
for _i in SimConfig.INVENTORY_SLOTS:
|
|
inventory.append(b.get_u8())
|
|
snap["inventory"] = inventory
|
|
|
|
var lcount := b.get_u16()
|
|
for _i in lcount:
|
|
snap["loot"].append({
|
|
"id": b.get_u32(),
|
|
"pos": Vector2(b.get_float(), b.get_float()),
|
|
"item": b.get_u8(),
|
|
})
|
|
return snap
|
|
|
|
|
|
# --- Events -----------------------------------------------------------------
|
|
|
|
## Events the client never sees; the instance layer consumes them server-side.
|
|
## These describe a decision the server is about to act on, not an outcome --
|
|
## leaking them would tell a client about a message it might try to forge.
|
|
const SERVER_ONLY := [
|
|
SimEvent.Type.PORTAL_USED,
|
|
SimEvent.Type.ESCAPE_COMPLETED,
|
|
SimEvent.Type.RESPAWN_REQUESTED,
|
|
]
|
|
|
|
|
|
## [param server_tick] rides along so the client can fast-forward a bullet by
|
|
## however many ticks the packet spent in flight, instead of popping it in at
|
|
## the muzzle a round-trip late.
|
|
static func encode_events(server_tick: int, events: Array[Dictionary]) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u32(server_tick)
|
|
var count := 0
|
|
var body := StreamPeerBuffer.new()
|
|
body.big_endian = false
|
|
for ev in events:
|
|
var t := int(ev["t"])
|
|
if SERVER_ONLY.has(t):
|
|
continue
|
|
body.put_u8(t)
|
|
match t:
|
|
SimEvent.Type.BULLET_SPAWN:
|
|
body.put_u32(ev["uid"])
|
|
body.put_float(ev["pos"].x)
|
|
body.put_float(ev["pos"].y)
|
|
body.put_float(ev["vel"].x)
|
|
body.put_float(ev["vel"].y)
|
|
body.put_float(ev["r"])
|
|
body.put_u16(clampi(int(ev["life"]), 0, 65535))
|
|
body.put_u8(int(ev["kind"]))
|
|
body.put_u8(int(ev["team"]))
|
|
body.put_float(ev["accel"])
|
|
body.put_float(ev["turn"])
|
|
SimEvent.Type.BULLET_DESPAWN:
|
|
body.put_u32(ev["uid"])
|
|
SimEvent.Type.PLAYER_HIT:
|
|
body.put_u32(ev["peer"])
|
|
body.put_u16(clampi(int(ev["dmg"]), 0, 65535))
|
|
body.put_u16(clampi(int(ev["hp"]), 0, 65535))
|
|
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
|
|
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
|
|
body.put_u32(ev["peer"])
|
|
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
|
SimEvent.Type.ITEM_DROPPED:
|
|
body.put_u32(ev["peer"])
|
|
# By index, like everywhere else on the wire. See Items.ORDER.
|
|
body.put_u8(Items.index_of(ev["item"]))
|
|
SimEvent.Type.PLAYER_RESPAWNED:
|
|
body.put_u32(ev["peer"])
|
|
body.put_float(ev["pos"].x)
|
|
body.put_float(ev["pos"].y)
|
|
SimEvent.Type.ENEMY_HIT:
|
|
body.put_u32(ev["id"])
|
|
body.put_u16(clampi(int(ev["dmg"]), 0, 65535))
|
|
body.put_u32(maxi(int(ev["hp"]), 0))
|
|
SimEvent.Type.ENEMY_DIED:
|
|
body.put_u32(ev["id"])
|
|
SimEvent.Type.BOSS_PHASE:
|
|
body.put_u8(clampi(int(ev["phase"]), 0, 255))
|
|
SimEvent.Type.BOSS_DIED:
|
|
pass
|
|
count += 1
|
|
b.put_u16(count)
|
|
b.put_data(body.data_array)
|
|
return b.data_array
|
|
|
|
|
|
## Returns { "tick": int, "events": Array[Dictionary] }.
|
|
static func decode_events(data: PackedByteArray) -> Dictionary:
|
|
var out: Array[Dictionary] = []
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
var server_tick := b.get_u32()
|
|
var count := b.get_u16()
|
|
for _i in count:
|
|
var t := b.get_u8()
|
|
var ev := {"t": t}
|
|
match t:
|
|
SimEvent.Type.BULLET_SPAWN:
|
|
ev["uid"] = b.get_u32()
|
|
ev["pos"] = Vector2(b.get_float(), b.get_float())
|
|
ev["vel"] = Vector2(b.get_float(), b.get_float())
|
|
ev["r"] = b.get_float()
|
|
ev["life"] = b.get_u16()
|
|
ev["kind"] = b.get_u8()
|
|
ev["team"] = b.get_u8()
|
|
ev["accel"] = b.get_float()
|
|
ev["turn"] = b.get_float()
|
|
SimEvent.Type.BULLET_DESPAWN:
|
|
ev["uid"] = b.get_u32()
|
|
SimEvent.Type.PLAYER_HIT:
|
|
ev["peer"] = b.get_u32()
|
|
ev["dmg"] = b.get_u16()
|
|
ev["hp"] = b.get_u16()
|
|
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
|
|
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
|
|
ev["peer"] = b.get_u32()
|
|
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
|
SimEvent.Type.ITEM_DROPPED:
|
|
ev["peer"] = b.get_u32()
|
|
ev["item"] = Items.by_index(b.get_u8())
|
|
SimEvent.Type.PLAYER_RESPAWNED:
|
|
ev["peer"] = b.get_u32()
|
|
ev["pos"] = Vector2(b.get_float(), b.get_float())
|
|
SimEvent.Type.ENEMY_HIT:
|
|
ev["id"] = b.get_u32()
|
|
ev["dmg"] = b.get_u16()
|
|
ev["hp"] = b.get_u32()
|
|
SimEvent.Type.ENEMY_DIED:
|
|
ev["id"] = b.get_u32()
|
|
SimEvent.Type.BOSS_PHASE:
|
|
ev["phase"] = b.get_u8()
|
|
SimEvent.Type.BOSS_DIED:
|
|
pass
|
|
out.append(ev)
|
|
return {"tick": server_tick, "events": out}
|
|
|
|
|
|
# --- Input ------------------------------------------------------------------
|
|
|
|
static func encode_inputs(frames: Array[InputFrame]) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u8(mini(frames.size(), 255))
|
|
for f in frames:
|
|
f.write(b)
|
|
return b.data_array
|
|
|
|
|
|
static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]:
|
|
var out: Array[InputFrame] = []
|
|
if data.size() < 1:
|
|
return out
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
var count := b.get_u8()
|
|
# A malformed or hostile packet must not make the server read past the end.
|
|
if data.size() < 1 + count * InputFrame.SIZE:
|
|
return out
|
|
for _i in count:
|
|
out.append(InputFrame.read(b))
|
|
return out
|
|
|
|
|
|
# --- Roster -----------------------------------------------------------------
|
|
# Who is online and where. Low frequency (membership changes only), so it is
|
|
# the one message that carries strings; everything hot stays fixed-width.
|
|
|
|
static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u8(mini(entries.size(), 255))
|
|
for e in entries:
|
|
b.put_u32(int(e["peer"]))
|
|
b.put_utf8_string(String(e["name"]))
|
|
b.put_u8(int(e["kind"]))
|
|
b.put_u32(int(e["instance"]))
|
|
b.put_u8(1 if e["alive"] else 0)
|
|
return b.data_array
|
|
|
|
|
|
static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
|
|
var out: Array[Dictionary] = []
|
|
if data.size() < 1:
|
|
return out
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
var count := b.get_u8()
|
|
for _i in count:
|
|
if b.get_available_bytes() < 4:
|
|
break
|
|
var peer := b.get_u32()
|
|
var display := _safe_utf8(b)
|
|
# kind, instance, alive.
|
|
if b.get_available_bytes() < 1 + 4 + 1:
|
|
break
|
|
out.append({
|
|
"peer": peer,
|
|
"name": display,
|
|
"kind": b.get_u8(),
|
|
"instance": b.get_u32(),
|
|
"alive": b.get_u8() == 1,
|
|
})
|
|
return out
|
|
|
|
|
|
# --- Map chunks -------------------------------------------------------------
|
|
|
|
static func encode_map_chunks(map: MapGrid, ids: Array) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u8(mini(ids.size(), 255))
|
|
for id in ids:
|
|
b.put_u16(int(id))
|
|
b.put_data(map.encode_chunk(int(id)))
|
|
return b.data_array
|
|
|
|
|
|
## Applies straight into [param map]. Chunk sizes come from the map's own
|
|
## dimensions, which the client learned at enter_instance, so a truncated or
|
|
## hostile packet cannot make it read past the end.
|
|
static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int:
|
|
if data.size() < 1:
|
|
return 0
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
var count := b.get_u8()
|
|
var applied := 0
|
|
for _i in count:
|
|
if b.get_available_bytes() < 2:
|
|
break
|
|
var id := b.get_u16()
|
|
var r := map.chunk_rect(id)
|
|
var n := r.size.x * r.size.y
|
|
if n <= 0 or b.get_available_bytes() < n:
|
|
break
|
|
map.apply_chunk(id, b.get_data(n)[1])
|
|
applied += 1
|
|
return applied
|
|
|
|
|
|
# --- Portals ----------------------------------------------------------------
|
|
# Sent once, with enter_instance. There are two of them and they never move, so
|
|
# this is about as cold as a message gets -- it is a codec only because the
|
|
# count is variable and RPC arguments are not.
|
|
|
|
static func encode_portals(portals: Array[SimPortal]) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u8(mini(portals.size(), 255))
|
|
for portal in portals:
|
|
b.put_float(portal.pos.x)
|
|
b.put_float(portal.pos.y)
|
|
# By index, like item ids. See Dungeons.ORDER.
|
|
b.put_u8(Dungeons.index_of(portal.dungeon))
|
|
return b.data_array
|
|
|
|
|
|
## Returns [{ "pos": Vector2, "dungeon": StringName }].
|
|
static func decode_portals(data: PackedByteArray) -> Array[Dictionary]:
|
|
var out: Array[Dictionary] = []
|
|
if data.size() < 1:
|
|
return out
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
var count := b.get_u8()
|
|
for _i in count:
|
|
# 4 + 4 + 1. A truncated packet gives back fewer portals rather than
|
|
# reading past the end and inventing one at a garbage position.
|
|
if b.get_available_bytes() < 9:
|
|
break
|
|
out.append({
|
|
"pos": Vector2(b.get_float(), b.get_float()),
|
|
"dungeon": Dungeons.by_index(b.get_u8()),
|
|
})
|
|
return out
|
|
|
|
|
|
# --- Upgrade state ----------------------------------------------------------
|
|
# Sent to one peer when its played character's upgrades change: a level gained,
|
|
# a choice spent, a character swapped in. Cold, and about the played character
|
|
# only -- nobody needs to know what anyone else has taken.
|
|
|
|
static func encode_upgrade_state(pending: int, offer: Array[StringName],
|
|
taken: Array[StringName]) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u8(clampi(pending, 0, 255))
|
|
b.put_u8(mini(offer.size(), 255))
|
|
for id in offer:
|
|
b.put_u8(Upgrades.index_of(id))
|
|
b.put_u16(mini(taken.size(), 65535))
|
|
for id in taken:
|
|
b.put_u8(Upgrades.index_of(id))
|
|
return b.data_array
|
|
|
|
|
|
## Returns { "pending": int, "offer": Array[StringName], "taken": Array[StringName] }.
|
|
static func decode_upgrade_state(data: PackedByteArray) -> Dictionary:
|
|
var offer: Array[StringName] = []
|
|
var taken: Array[StringName] = []
|
|
var out := {"pending": 0, "offer": offer, "taken": taken}
|
|
if data.size() < 2:
|
|
return out
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
out["pending"] = b.get_u8()
|
|
var offer_count := b.get_u8()
|
|
for _i in offer_count:
|
|
if b.get_available_bytes() < 1:
|
|
return out
|
|
var id := Upgrades.by_index(b.get_u8())
|
|
if not id.is_empty():
|
|
offer.append(id)
|
|
if b.get_available_bytes() < 2:
|
|
return out
|
|
var taken_count := b.get_u16()
|
|
for _i in taken_count:
|
|
if b.get_available_bytes() < 1:
|
|
return out
|
|
var id := Upgrades.by_index(b.get_u8())
|
|
if not id.is_empty():
|
|
taken.append(id)
|
|
return out
|
|
|
|
|
|
# --- 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())
|
|
for i in SimConfig.INVENTORY_SLOTS:
|
|
b.put_u8(Items.index_of(c.inventory[i] if i < c.inventory.size() else Items.NONE))
|
|
return b.data_array
|
|
|
|
|
|
## Length-prefixed string read that refuses to run off the end.
|
|
##
|
|
## StreamPeerBuffer.get_utf8_string() reads a length and then that many bytes,
|
|
## and pushes an engine error if the buffer is short -- so a truncated or
|
|
## hostile packet turns into error spam plus a garbage value. Returns an empty
|
|
## string and leaves the cursor at the end instead, which callers detect via
|
|
## get_available_bytes().
|
|
static func _safe_utf8(b: StreamPeerBuffer) -> String:
|
|
if b.get_available_bytes() < 4:
|
|
b.seek(b.get_size())
|
|
return ""
|
|
var length := b.get_u32()
|
|
if length > b.get_available_bytes():
|
|
b.seek(b.get_size())
|
|
return ""
|
|
return b.get_data(length)[1].get_string_from_utf8() if length > 0 else ""
|
|
|
|
|
|
## 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 := _safe_utf8(b)
|
|
if b.get_available_bytes() < 1:
|
|
return {"selected": selected, "characters": out}
|
|
var count := b.get_u8()
|
|
# Bytes each entry needs after its two strings: level, xp, progress,
|
|
# max_hp, active, colour, then one byte per inventory slot.
|
|
var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS
|
|
for _i in count:
|
|
var id := _safe_utf8(b)
|
|
var display := _safe_utf8(b)
|
|
if id.is_empty() or b.get_available_bytes() < fixed:
|
|
break
|
|
out.append({
|
|
"id": id,
|
|
"name": display,
|
|
"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()),
|
|
"inventory": _read_inventory(b),
|
|
})
|
|
return {"selected": selected, "characters": out}
|
|
|
|
|
|
static func _read_inventory(b: StreamPeerBuffer) -> Array[int]:
|
|
var out: Array[int] = []
|
|
for _i in SimConfig.INVENTORY_SLOTS:
|
|
out.append(b.get_u8())
|
|
return out
|